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
@@ -187,7 +187,7 @@ export class DeduplicateUniqueFieldsCommand extends ActiveOrSuspendedWorkspacesM
}) {
const workspaceMemberRepository = dataSource.getRepository(
'workspaceMember',
true,
{ shouldBypassPermissionChecks: true },
);
const duplicates = await workspaceMemberRepository
@@ -245,7 +245,9 @@ export class DeduplicateUniqueFieldsCommand extends ActiveOrSuspendedWorkspacesM
dataSource: WorkspaceDataSource;
dryRun: boolean;
}) {
const companyRepository = dataSource.getRepository('company', true);
const companyRepository = dataSource.getRepository('company', {
shouldBypassPermissionChecks: true,
});
const duplicates = await companyRepository
.createQueryBuilder('company')
@@ -303,7 +305,9 @@ export class DeduplicateUniqueFieldsCommand extends ActiveOrSuspendedWorkspacesM
dataSource: WorkspaceDataSource;
dryRun: boolean;
}) {
const personRepository = dataSource.getRepository('person', true);
const personRepository = dataSource.getRepository('person', {
shouldBypassPermissionChecks: true,
});
const duplicates = await personRepository
.createQueryBuilder('person')
@@ -79,19 +79,18 @@ export abstract class CommonBaseQueryRunnerService {
workspace.id,
);
const rolePermissionConfig = { unionOf: [roleId] };
const repository = workspaceDataSource.getRepository(
objectMetadataItemWithFieldMaps.nameSingular,
false,
roleId,
rolePermissionConfig,
authContext,
);
return {
workspaceDataSource,
repository,
isExecutedByApiKey: isDefined(authContext.apiKey),
roleId,
shouldBypassPermissionChecks: false,
rolePermissionConfig,
};
}
@@ -31,6 +31,7 @@ import { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/typ
import { ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps';
import { WorkspaceDataSource } from 'src/engine/twenty-orm/datasource/workspace.datasource';
import { WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.repository';
import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config';
@Injectable()
export class CommonCreateManyQueryRunnerService extends CommonBaseQueryRunnerService {
@@ -62,15 +63,11 @@ export class CommonCreateManyQueryRunnerService extends CommonBaseQueryRunnerSer
}
});
const {
workspaceDataSource,
repository,
roleId,
shouldBypassPermissionChecks,
} = await this.prepareQueryRunnerContext({
authContext,
objectMetadataItemWithFieldMaps,
});
const { workspaceDataSource, repository, rolePermissionConfig } =
await this.prepareQueryRunnerContext({
authContext,
objectMetadataItemWithFieldMaps,
});
const processedArgs = await this.processQueryArgs({
authContext,
@@ -98,10 +95,9 @@ export class CommonCreateManyQueryRunnerService extends CommonBaseQueryRunnerSer
records: upsertedRecords,
objectMetadataItemWithFieldMaps,
objectMetadataMaps,
roleId,
rolePermissionConfig,
authContext,
workspaceDataSource,
shouldBypassPermissionChecks,
});
return upsertedRecords;
@@ -371,19 +367,17 @@ export class CommonCreateManyQueryRunnerService extends CommonBaseQueryRunnerSer
records,
objectMetadataItemWithFieldMaps,
objectMetadataMaps,
roleId,
rolePermissionConfig,
authContext,
workspaceDataSource,
shouldBypassPermissionChecks,
}: {
args: CreateManyQueryArgs;
records: ObjectRecord[];
objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps;
objectMetadataMaps: ObjectMetadataMaps;
roleId?: string;
rolePermissionConfig?: RolePermissionConfig;
authContext: AuthContext;
workspaceDataSource: WorkspaceDataSource;
shouldBypassPermissionChecks: boolean;
}): Promise<void> {
if (!args.selectedFieldsResult.relations) {
return;
@@ -401,8 +395,7 @@ export class CommonCreateManyQueryRunnerService extends CommonBaseQueryRunnerSer
limit: QUERY_MAX_RECORDS,
authContext,
workspaceDataSource,
roleId,
shouldBypassPermissionChecks,
rolePermissionConfig,
selectedFields: args.selectedFieldsResult.select,
});
}
@@ -60,15 +60,11 @@ export class CommonFindManyQueryRunnerService extends CommonBaseQueryRunnerServi
);
}
const {
workspaceDataSource,
repository,
roleId,
shouldBypassPermissionChecks,
} = await this.prepareQueryRunnerContext({
authContext,
objectMetadataItemWithFieldMaps,
});
const { workspaceDataSource, repository, rolePermissionConfig } =
await this.prepareQueryRunnerContext({
authContext,
objectMetadataItemWithFieldMaps,
});
const processedArgs = await this.processQueryArgs({
authContext,
@@ -195,8 +191,7 @@ export class CommonFindManyQueryRunnerService extends CommonBaseQueryRunnerServi
limit: QUERY_MAX_RECORDS,
authContext,
workspaceDataSource,
roleId,
shouldBypassPermissionChecks,
rolePermissionConfig,
selectedFields: processedArgs.selectedFieldsResult.select,
});
}
@@ -46,15 +46,11 @@ export class CommonFindOneQueryRunnerService extends CommonBaseQueryRunnerServic
);
}
const {
workspaceDataSource,
repository,
roleId,
shouldBypassPermissionChecks,
} = await this.prepareQueryRunnerContext({
authContext,
objectMetadataItemWithFieldMaps,
});
const { workspaceDataSource, repository, rolePermissionConfig } =
await this.prepareQueryRunnerContext({
authContext,
objectMetadataItemWithFieldMaps,
});
const processedArgs = await this.processQueryArgs({
authContext,
@@ -128,8 +124,7 @@ export class CommonFindOneQueryRunnerService extends CommonBaseQueryRunnerServic
limit: QUERY_MAX_RECORDS,
authContext,
workspaceDataSource,
roleId,
shouldBypassPermissionChecks,
rolePermissionConfig,
selectedFields: args.selectedFieldsResult.select,
});
}
@@ -20,6 +20,7 @@ import { type ObjectMetadataMaps } from 'src/engine/metadata-modules/types/objec
import { getObjectMetadataMapItemByNameSingular } from 'src/engine/metadata-modules/utils/get-object-metadata-map-item-by-name-singular.util';
import { type WorkspaceDataSource } from 'src/engine/twenty-orm/datasource/workspace.datasource';
import { type WorkspaceSelectQueryBuilder } from 'src/engine/twenty-orm/repository/workspace-select-query-builder';
import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config';
import { isFieldMetadataEntityOfType } from 'src/engine/utils/is-field-metadata-of-type.util';
@Injectable()
@@ -36,8 +37,7 @@ export class ProcessNestedRelationsV2Helper {
limit,
authContext,
workspaceDataSource,
roleId,
shouldBypassPermissionChecks,
rolePermissionConfig,
selectedFields,
}: {
objectMetadataMaps: ObjectMetadataMaps;
@@ -50,10 +50,9 @@ export class ProcessNestedRelationsV2Helper {
limit: number;
authContext: AuthContext;
workspaceDataSource: WorkspaceDataSource;
shouldBypassPermissionChecks: boolean;
rolePermissionConfig?: RolePermissionConfig;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
selectedFields: Record<string, any>;
roleId?: string;
}): Promise<void> {
const processRelationTasks = Object.entries(relations).map(
([sourceFieldName, nestedRelations]) =>
@@ -68,8 +67,7 @@ export class ProcessNestedRelationsV2Helper {
limit,
authContext,
workspaceDataSource,
shouldBypassPermissionChecks,
roleId,
rolePermissionConfig,
selectedFields:
selectedFields[sourceFieldName] instanceof Object
? selectedFields[sourceFieldName]
@@ -91,8 +89,7 @@ export class ProcessNestedRelationsV2Helper {
limit,
authContext,
workspaceDataSource,
shouldBypassPermissionChecks,
roleId,
rolePermissionConfig,
selectedFields,
}: {
objectMetadataMaps: ObjectMetadataMaps;
@@ -106,8 +103,7 @@ export class ProcessNestedRelationsV2Helper {
limit: number;
authContext: AuthContext;
workspaceDataSource: WorkspaceDataSource;
shouldBypassPermissionChecks: boolean;
roleId?: string;
rolePermissionConfig?: RolePermissionConfig;
selectedFields: Record<string, unknown>;
}): Promise<void> {
const sourceFieldMetadata = getFieldMetadataFromGraphQLField({
@@ -147,8 +143,7 @@ export class ProcessNestedRelationsV2Helper {
const targetObjectRepository = workspaceDataSource.getRepository(
targetObjectMetadata.nameSingular,
shouldBypassPermissionChecks,
roleId,
rolePermissionConfig,
);
const targetObjectNameSingular = targetObjectMetadata.nameSingular;
@@ -240,8 +235,7 @@ export class ProcessNestedRelationsV2Helper {
limit,
authContext,
workspaceDataSource,
shouldBypassPermissionChecks,
roleId,
rolePermissionConfig,
selectedFields,
});
}
@@ -9,6 +9,7 @@ import { type AuthContext } from 'src/engine/core-modules/auth/types/auth-contex
import { type ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
import { type ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps';
import { type WorkspaceDataSource } from 'src/engine/twenty-orm/datasource/workspace.datasource';
import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config';
@Injectable()
export class ProcessNestedRelationsHelper {
@@ -26,8 +27,7 @@ export class ProcessNestedRelationsHelper {
limit,
authContext,
workspaceDataSource,
shouldBypassPermissionChecks,
roleId,
rolePermissionConfig,
selectedFields,
}: {
objectMetadataMaps: ObjectMetadataMaps;
@@ -40,10 +40,9 @@ export class ProcessNestedRelationsHelper {
limit: number;
authContext: AuthContext;
workspaceDataSource: WorkspaceDataSource;
shouldBypassPermissionChecks: boolean;
rolePermissionConfig?: RolePermissionConfig;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
selectedFields: Record<string, any>;
roleId?: string;
}): Promise<void> {
return this.processNestedRelationsV2Helper.processNestedRelations({
objectMetadataMaps,
@@ -55,8 +54,7 @@ export class ProcessNestedRelationsHelper {
limit,
authContext,
workspaceDataSource,
shouldBypassPermissionChecks,
roleId,
rolePermissionConfig,
selectedFields,
});
}
@@ -40,6 +40,7 @@ import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role
import { type WorkspaceDataSource } from 'src/engine/twenty-orm/datasource/workspace.datasource';
import { type WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.repository';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config';
export type GraphqlQueryResolverExecutionArgs<Input extends ResolverArgs> = {
args: Input;
@@ -49,8 +50,7 @@ export type GraphqlQueryResolverExecutionArgs<Input extends ResolverArgs> = {
graphqlQueryParser: GraphqlQueryParser;
graphqlQuerySelectedFieldsResult: GraphqlQuerySelectedFieldsResult;
isExecutedByApiKey: boolean;
roleId?: string;
shouldBypassPermissionChecks: boolean;
rolePermissionConfig?: RolePermissionConfig;
};
@Injectable()
@@ -120,7 +120,6 @@ export abstract class GraphqlQueryBaseResolverService<
)) as Input;
let roleId: string | undefined;
let shouldBypassPermissionChecks = false;
if (isDefined(authContext.apiKey)) {
roleId = await this.apiKeyRoleService.getRoleIdForApiKey(
@@ -152,11 +151,12 @@ export abstract class GraphqlQueryBaseResolverService<
PermissionsExceptionCode.NO_AUTHENTICATION_CONTEXT,
);
}
const rolePermissionConfig: RolePermissionConfig | undefined = roleId
? { unionOf: [roleId] }
: undefined;
const repository = workspaceDataSource.getRepository(
objectMetadataItemWithFieldMaps.nameSingular,
shouldBypassPermissionChecks,
roleId,
rolePermissionConfig,
authContext,
);
@@ -182,8 +182,7 @@ export abstract class GraphqlQueryBaseResolverService<
graphqlQueryParser,
graphqlQuerySelectedFieldsResult,
isExecutedByApiKey: isDefined(authContext.apiKey),
roleId,
shouldBypassPermissionChecks,
rolePermissionConfig,
};
const results = await this.resolve(
graphqlQueryResolverExecutionArgs,
@@ -55,8 +55,6 @@ export class GraphqlQueryCreateManyResolverService extends GraphqlQueryBaseResol
const { objectMetadataItemWithFieldMaps, objectMetadataMaps } =
executionArgs.options;
const { roleId } = executionArgs;
const objectRecords = await this.insertOrUpsertRecords(executionArgs);
const upsertedRecords = await this.fetchUpsertedRecords(
@@ -71,7 +69,6 @@ export class GraphqlQueryCreateManyResolverService extends GraphqlQueryBaseResol
records: upsertedRecords,
objectMetadataItemWithFieldMaps,
objectMetadataMaps,
roleId,
});
return this.formatRecordsForResponse(
@@ -462,13 +459,11 @@ export class GraphqlQueryCreateManyResolverService extends GraphqlQueryBaseResol
records,
objectMetadataItemWithFieldMaps,
objectMetadataMaps,
roleId,
}: {
executionArgs: GraphqlQueryResolverExecutionArgs<CreateManyResolverArgs>;
records: ObjectRecord[];
objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps;
objectMetadataMaps: ObjectMetadataMaps;
roleId?: string;
}): Promise<void> {
if (!executionArgs.graphqlQuerySelectedFieldsResult.relations) {
return;
@@ -482,8 +477,7 @@ export class GraphqlQueryCreateManyResolverService extends GraphqlQueryBaseResol
limit: QUERY_MAX_RECORDS,
authContext: executionArgs.options.authContext,
workspaceDataSource: executionArgs.workspaceDataSource,
roleId,
shouldBypassPermissionChecks: executionArgs.shouldBypassPermissionChecks,
rolePermissionConfig: executionArgs.rolePermissionConfig,
selectedFields: executionArgs.graphqlQuerySelectedFieldsResult.select,
});
}
@@ -27,8 +27,6 @@ export class GraphqlQueryDeleteManyResolverService extends GraphqlQueryBaseResol
const { authContext, objectMetadataItemWithFieldMaps, objectMetadataMaps } =
executionArgs.options;
const { roleId } = executionArgs;
const queryBuilder = executionArgs.repository.createQueryBuilder(
objectMetadataItemWithFieldMaps.nameSingular,
);
@@ -66,9 +64,7 @@ export class GraphqlQueryDeleteManyResolverService extends GraphqlQueryBaseResol
limit: QUERY_MAX_RECORDS,
authContext,
workspaceDataSource: executionArgs.workspaceDataSource,
roleId,
shouldBypassPermissionChecks:
executionArgs.shouldBypassPermissionChecks,
rolePermissionConfig: executionArgs.rolePermissionConfig,
selectedFields: executionArgs.graphqlQuerySelectedFieldsResult.select,
});
}
@@ -30,8 +30,6 @@ export class GraphqlQueryDeleteOneResolverService extends GraphqlQueryBaseResolv
const { authContext, objectMetadataItemWithFieldMaps, objectMetadataMaps } =
executionArgs.options;
const { roleId } = executionArgs;
const queryBuilder = executionArgs.repository.createQueryBuilder(
objectMetadataItemWithFieldMaps.nameSingular,
);
@@ -67,9 +65,7 @@ export class GraphqlQueryDeleteOneResolverService extends GraphqlQueryBaseResolv
limit: QUERY_MAX_RECORDS,
authContext,
workspaceDataSource: executionArgs.workspaceDataSource,
roleId,
shouldBypassPermissionChecks:
executionArgs.shouldBypassPermissionChecks,
rolePermissionConfig: executionArgs.rolePermissionConfig,
selectedFields: executionArgs.graphqlQuerySelectedFieldsResult.select,
});
}
@@ -24,8 +24,6 @@ export class GraphqlQueryDestroyManyResolverService extends GraphqlQueryBaseReso
const { authContext, objectMetadataItemWithFieldMaps, objectMetadataMaps } =
executionArgs.options;
const { roleId } = executionArgs;
const queryBuilder = executionArgs.repository.createQueryBuilder(
objectMetadataItemWithFieldMaps.nameSingular,
);
@@ -58,9 +56,7 @@ export class GraphqlQueryDestroyManyResolverService extends GraphqlQueryBaseReso
limit: QUERY_MAX_RECORDS,
authContext,
workspaceDataSource: executionArgs.workspaceDataSource,
roleId,
shouldBypassPermissionChecks:
executionArgs.shouldBypassPermissionChecks,
rolePermissionConfig: executionArgs.rolePermissionConfig,
selectedFields: executionArgs.graphqlQuerySelectedFieldsResult.select,
});
}
@@ -28,8 +28,6 @@ export class GraphqlQueryDestroyOneResolverService extends GraphqlQueryBaseResol
const { authContext, objectMetadataItemWithFieldMaps, objectMetadataMaps } =
executionArgs.options;
const { roleId } = executionArgs;
const queryBuilder = executionArgs.repository.createQueryBuilder(
objectMetadataItemWithFieldMaps.nameSingular,
);
@@ -64,9 +62,7 @@ export class GraphqlQueryDestroyOneResolverService extends GraphqlQueryBaseResol
limit: QUERY_MAX_RECORDS,
authContext,
workspaceDataSource: executionArgs.workspaceDataSource,
roleId,
shouldBypassPermissionChecks:
executionArgs.shouldBypassPermissionChecks,
rolePermissionConfig: executionArgs.rolePermissionConfig,
selectedFields: executionArgs.graphqlQuerySelectedFieldsResult.select,
});
}
@@ -47,8 +47,6 @@ export class GraphqlQueryFindManyResolverService extends GraphqlQueryBaseResolve
const objectMetadataNameSingular =
objectMetadataItemWithFieldMaps.nameSingular;
const { roleId } = executionArgs;
const queryBuilder = executionArgs.repository.createQueryBuilder(
objectMetadataNameSingular,
);
@@ -163,9 +161,7 @@ export class GraphqlQueryFindManyResolverService extends GraphqlQueryBaseResolve
limit: QUERY_MAX_RECORDS,
authContext,
workspaceDataSource: executionArgs.workspaceDataSource,
roleId,
shouldBypassPermissionChecks:
executionArgs.shouldBypassPermissionChecks,
rolePermissionConfig: executionArgs.rolePermissionConfig,
selectedFields: executionArgs.graphqlQuerySelectedFieldsResult.select,
});
}
@@ -34,8 +34,6 @@ export class GraphqlQueryFindOneResolverService extends GraphqlQueryBaseResolver
const { authContext, objectMetadataItemWithFieldMaps, objectMetadataMaps } =
executionArgs.options;
const { roleId } = executionArgs;
const queryBuilder = executionArgs.repository.createQueryBuilder(
objectMetadataItemWithFieldMaps.nameSingular,
);
@@ -82,9 +80,7 @@ export class GraphqlQueryFindOneResolverService extends GraphqlQueryBaseResolver
limit: QUERY_MAX_RECORDS,
authContext,
workspaceDataSource: executionArgs.workspaceDataSource,
roleId,
shouldBypassPermissionChecks:
executionArgs.shouldBypassPermissionChecks,
rolePermissionConfig: executionArgs.rolePermissionConfig,
selectedFields: executionArgs.graphqlQuerySelectedFieldsResult.select,
});
}
@@ -47,7 +47,6 @@ export class GraphqlQueryMergeManyResolverService extends GraphqlQueryBaseResolv
const { authContext, objectMetadataItemWithFieldMaps, objectMetadataMaps } =
executionArgs.options;
const { ids, conflictPriorityIndex, dryRun } = executionArgs.args;
const { roleId } = executionArgs;
const recordsToMerge = await this.fetchRecordsToMerge(executionArgs, ids);
@@ -96,12 +95,11 @@ export class GraphqlQueryMergeManyResolverService extends GraphqlQueryBaseResolv
mergedData,
);
if (roleId) {
if (executionArgs.rolePermissionConfig) {
await this.processNestedRelations({
executionArgs,
updatedRecords: [updatedRecord],
authContext,
roleId,
});
}
@@ -335,8 +333,7 @@ export class GraphqlQueryMergeManyResolverService extends GraphqlQueryBaseResolv
try {
const repository = executionArgs.workspaceDataSource.getRepository(
relationField.objectMetadata.nameSingular,
executionArgs.isExecutedByApiKey,
executionArgs.roleId,
executionArgs.rolePermissionConfig,
);
const whereCondition = { [relationField.joinColumnName]: In(fromIds) };
@@ -363,12 +360,10 @@ export class GraphqlQueryMergeManyResolverService extends GraphqlQueryBaseResolv
executionArgs,
updatedRecords,
authContext,
roleId,
}: {
executionArgs: GraphqlQueryResolverExecutionArgs<MergeManyResolverArgs>;
updatedRecords: ObjectRecord[];
authContext: AuthContext;
roleId: string;
}): Promise<void> {
const { objectMetadataMaps, objectMetadataItemWithFieldMaps } =
executionArgs.options;
@@ -382,9 +377,7 @@ export class GraphqlQueryMergeManyResolverService extends GraphqlQueryBaseResolv
limit: QUERY_MAX_RECORDS,
authContext,
workspaceDataSource: executionArgs.workspaceDataSource,
roleId,
shouldBypassPermissionChecks:
executionArgs.shouldBypassPermissionChecks,
rolePermissionConfig: executionArgs.rolePermissionConfig,
selectedFields: executionArgs.graphqlQuerySelectedFieldsResult.select,
});
}
@@ -27,8 +27,6 @@ export class GraphqlQueryRestoreManyResolverService extends GraphqlQueryBaseReso
const { authContext, objectMetadataItemWithFieldMaps, objectMetadataMaps } =
executionArgs.options;
const { roleId } = executionArgs;
const queryBuilder = executionArgs.repository.createQueryBuilder(
objectMetadataItemWithFieldMaps.nameSingular,
);
@@ -66,9 +64,7 @@ export class GraphqlQueryRestoreManyResolverService extends GraphqlQueryBaseReso
limit: QUERY_MAX_RECORDS,
authContext,
workspaceDataSource: executionArgs.workspaceDataSource,
roleId,
shouldBypassPermissionChecks:
executionArgs.shouldBypassPermissionChecks,
rolePermissionConfig: executionArgs.rolePermissionConfig,
selectedFields: executionArgs.graphqlQuerySelectedFieldsResult.select,
});
}
@@ -30,8 +30,6 @@ export class GraphqlQueryRestoreOneResolverService extends GraphqlQueryBaseResol
const { authContext, objectMetadataItemWithFieldMaps, objectMetadataMaps } =
executionArgs.options;
const { roleId } = executionArgs;
const queryBuilder = executionArgs.repository.createQueryBuilder(
objectMetadataItemWithFieldMaps.nameSingular,
);
@@ -68,9 +66,7 @@ export class GraphqlQueryRestoreOneResolverService extends GraphqlQueryBaseResol
limit: QUERY_MAX_RECORDS,
authContext,
workspaceDataSource: executionArgs.workspaceDataSource,
roleId,
shouldBypassPermissionChecks:
executionArgs.shouldBypassPermissionChecks,
rolePermissionConfig: executionArgs.rolePermissionConfig,
selectedFields: executionArgs.graphqlQuerySelectedFieldsResult.select,
});
}
@@ -27,8 +27,6 @@ export class GraphqlQueryUpdateManyResolverService extends GraphqlQueryBaseResol
const { authContext, objectMetadataItemWithFieldMaps, objectMetadataMaps } =
executionArgs.options;
const { roleId } = executionArgs;
const queryBuilder = executionArgs.repository.createQueryBuilder(
objectMetadataItemWithFieldMaps.nameSingular,
);
@@ -67,9 +65,7 @@ export class GraphqlQueryUpdateManyResolverService extends GraphqlQueryBaseResol
limit: QUERY_MAX_RECORDS,
authContext,
workspaceDataSource: executionArgs.workspaceDataSource,
roleId,
shouldBypassPermissionChecks:
executionArgs.shouldBypassPermissionChecks,
rolePermissionConfig: executionArgs.rolePermissionConfig,
selectedFields: executionArgs.graphqlQuerySelectedFieldsResult.select,
});
}
@@ -30,8 +30,6 @@ export class GraphqlQueryUpdateOneResolverService extends GraphqlQueryBaseResolv
const { authContext, objectMetadataItemWithFieldMaps, objectMetadataMaps } =
executionArgs.options;
const { roleId } = executionArgs;
const queryBuilder = executionArgs.repository.createQueryBuilder(
objectMetadataItemWithFieldMaps.nameSingular,
);
@@ -68,9 +66,7 @@ export class GraphqlQueryUpdateOneResolverService extends GraphqlQueryBaseResolv
limit: QUERY_MAX_RECORDS,
authContext,
workspaceDataSource: executionArgs.workspaceDataSource,
roleId,
shouldBypassPermissionChecks:
executionArgs.shouldBypassPermissionChecks,
rolePermissionConfig: executionArgs.rolePermissionConfig,
selectedFields: executionArgs.graphqlQuerySelectedFieldsResult.select,
});
}
@@ -175,8 +175,9 @@ export abstract class RestApiBaseHandler {
const repository = workspaceDataSource.getRepository<ObjectRecord>(
objectMetadataNameSingular,
false,
roleId,
{
unionOf: [roleId],
},
);
const objectMetadataPermissions =
@@ -285,7 +285,7 @@ describe('McpService', () => {
});
expect(toolService.listTools).toHaveBeenCalledWith(
mockAdminRoleId,
{ unionOf: [mockAdminRoleId] },
mockWorkspace.id,
);
expect(mockTool.execute).toHaveBeenCalledWith(
@@ -9,6 +9,7 @@ import { type ToolInput } from 'src/engine/core-modules/tool/types/tool-input.ty
import { type Tool } from 'src/engine/core-modules/tool/types/tool.type';
import { PermissionFlagType } from 'src/engine/metadata-modules/permissions/constants/permission-flag-type.constants';
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config';
const createMockToolRegistry = () => ({
getAllToolTypes: jest.fn(),
@@ -18,7 +19,7 @@ const createMockToolRegistry = () => ({
const createMockPermissions = () => ({
hasToolPermission: jest.fn<
Promise<boolean>,
[string, string, PermissionFlagType]
[RolePermissionConfig, string, PermissionFlagType]
>(),
});
@@ -89,7 +90,9 @@ describe('ToolAdapterService', () => {
expect(Object.keys(toolsNoContext)).toContain('http_request');
const toolsWithPartialContext = await service.getTools('role-1');
const toolsWithPartialContext = await service.getTools({
unionOf: ['role-1'],
});
expect(Object.keys(toolsWithPartialContext)).toContain('http_request');
});
@@ -99,7 +102,9 @@ describe('ToolAdapterService', () => {
expect(Object.keys(toolsNoContext)).not.toContain('send_email');
const toolsRoleOnly = await service.getTools('role-1');
const toolsRoleOnly = await service.getTools({
unionOf: ['role-1'],
});
expect(Object.keys(toolsRoleOnly)).not.toContain('send_email');
@@ -111,10 +116,10 @@ describe('ToolAdapterService', () => {
it('should include flagged tools when permission is granted', async () => {
mockPermissions.hasToolPermission.mockResolvedValueOnce(true);
const tools = await service.getTools('role-1', 'ws-1');
const tools = await service.getTools({ unionOf: ['role-1'] }, 'ws-1');
expect(mockPermissions.hasToolPermission).toHaveBeenCalledWith(
'role-1',
{ unionOf: ['role-1'] },
'ws-1',
PermissionFlagType.SEND_EMAIL_TOOL,
);
@@ -125,7 +130,7 @@ describe('ToolAdapterService', () => {
it('should exclude flagged tools when permission is denied', async () => {
mockPermissions.hasToolPermission.mockResolvedValueOnce(false);
const tools = await service.getTools('role-1', 'ws-1');
const tools = await service.getTools({ unionOf: ['role-1'] }, 'ws-1');
expect(Object.keys(tools)).not.toContain('send_email');
});
@@ -69,10 +69,10 @@ describe('ToolService', () => {
data: {
[roleId]: {
[testObject.id]: {
canRead: true,
canUpdate: true,
canSoftDelete: true,
canDestroy: false,
canReadObjectRecords: true,
canUpdateObjectRecords: true,
canSoftDeleteObjectRecords: true,
canDestroyObjectRecords: false,
restrictedFields: {},
},
},
@@ -128,7 +128,7 @@ describe('ToolService', () => {
describe('listTools', () => {
it('should return tools based on role permissions', async () => {
const tools = await service.listTools(roleId, workspaceId);
const tools = await service.listTools({ unionOf: [roleId] }, workspaceId);
expect(
permissionsCacheService.getRolesPermissionsFromCache,
@@ -124,7 +124,10 @@ export class McpService {
apiKey,
);
const toolSet = await this.toolService.listTools(roleId, workspace.id);
const toolSet = await this.toolService.listTools(
{ unionOf: [roleId] },
workspace.id,
);
if (method === 'tools/call' && params) {
return await this.handleToolCall(id, toolSet, params);
@@ -7,6 +7,7 @@ import { type ToolInput } from 'src/engine/core-modules/tool/types/tool-input.ty
import { type Tool } from 'src/engine/core-modules/tool/types/tool.type';
import { type PermissionFlagType } from 'src/engine/metadata-modules/permissions/constants/permission-flag-type.constants';
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config';
@Injectable()
export class ToolAdapterService {
@@ -15,7 +16,10 @@ export class ToolAdapterService {
private readonly permissionsService: PermissionsService,
) {}
async getTools(roleId?: string, workspaceId?: string): Promise<ToolSet> {
async getTools(
rolePermissionConfig?: RolePermissionConfig,
workspaceId?: string,
): Promise<ToolSet> {
const tools: ToolSet = {};
for (const toolType of this.toolRegistry.getAllToolTypes()) {
@@ -23,9 +27,9 @@ export class ToolAdapterService {
if (!tool.flag) {
tools[toolType.toLowerCase()] = this.createToolSet(tool);
} else if (roleId && workspaceId) {
} else if (rolePermissionConfig && workspaceId) {
const hasPermission = await this.permissionsService.hasToolPermission(
roleId,
rolePermissionConfig,
workspaceId,
tool.flag as PermissionFlagType,
);
@@ -18,6 +18,8 @@ import { type ActorMetadata } from 'src/engine/metadata-modules/field-metadata/c
import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service';
import { WorkspacePermissionsCacheService } from 'src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache.service';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config';
import { computePermissionIntersection } from 'src/engine/twenty-orm/utils/compute-permission-intersection.util';
@Injectable()
export class ToolService {
@@ -32,7 +34,7 @@ export class ToolService {
) {}
async listTools(
roleId: string,
rolePermissionConfig: RolePermissionConfig,
workspaceId: string,
actorContext?: ActorMetadata,
): Promise<ToolSet> {
@@ -43,7 +45,29 @@ export class ToolService {
workspaceId,
});
const objectPermissions = rolesPermissions[roleId];
let objectPermissions;
if ('unionOf' in rolePermissionConfig) {
if (rolePermissionConfig.unionOf.length === 1) {
objectPermissions = rolesPermissions[rolePermissionConfig.unionOf[0]];
} else {
// TODO: Implement union logic for multiple roles
throw new Error(
'Union permission logic for multiple roles not yet implemented',
);
}
} else if ('intersectionOf' in rolePermissionConfig) {
const allRolePermissions = rolePermissionConfig.intersectionOf.map(
(roleId: string) => rolesPermissions[roleId],
);
objectPermissions =
allRolePermissions.length === 1
? allRolePermissions[0]
: computePermissionIntersection(allRolePermissions);
} else {
return tools;
}
const allObjectMetadata =
await this.objectMetadataService.findManyWithinWorkspace(workspaceId, {
@@ -65,7 +89,7 @@ export class ToolService {
return;
}
if (objectPermission.canUpdate) {
if (objectPermission.canUpdateObjectRecords) {
tools[`create_${objectMetadata.nameSingular}`] = {
description: `Create a new ${objectMetadata.labelSingular} record. Provide all required fields and any optional fields you want to set. The system will automatically handle timestamps and IDs. Returns the created record with all its data.`,
inputSchema: getRecordInputSchema(objectMetadata),
@@ -74,7 +98,7 @@ export class ToolService {
objectName: objectMetadata.nameSingular,
objectRecord: parameters.input,
workspaceId,
roleId,
rolePermissionConfig,
createdBy: actorContext,
});
},
@@ -91,13 +115,13 @@ export class ToolService {
objectRecordId: id,
objectRecord,
workspaceId,
roleId,
rolePermissionConfig,
});
},
};
}
if (objectPermission.canRead) {
if (objectPermission.canReadObjectRecords) {
tools[`find_${objectMetadata.nameSingular}`] = {
description: `Search for ${objectMetadata.labelSingular} records using flexible filtering criteria. Supports exact matches, pattern matching, ranges, and null checks. Use limit/offset for pagination. Returns an array of matching records with their full data.`,
inputSchema: generateFindToolSchema(objectMetadata),
@@ -110,7 +134,7 @@ export class ToolService {
limit,
offset,
workspaceId,
roleId,
rolePermissionConfig,
});
},
};
@@ -124,13 +148,13 @@ export class ToolService {
filter: { id: { eq: parameters.input.id } },
limit: 1,
workspaceId,
roleId,
rolePermissionConfig,
});
},
};
}
if (objectPermission.canSoftDelete) {
if (objectPermission.canSoftDeleteObjectRecords) {
tools[`soft_delete_${objectMetadata.nameSingular}`] = {
description: `Soft delete a ${objectMetadata.labelSingular} record by marking it as deleted. The record remains in the database but is hidden from normal queries. This is reversible and preserves all data. Use this for temporary removal.`,
inputSchema: generateSoftDeleteToolSchema(),
@@ -139,7 +163,7 @@ export class ToolService {
objectName: objectMetadata.nameSingular,
objectRecordId: parameters.input.id,
workspaceId,
roleId,
rolePermissionConfig,
soft: true,
});
},
@@ -153,7 +177,7 @@ export class ToolService {
objectMetadata.nameSingular,
parameters.input,
workspaceId,
roleId,
rolePermissionConfig,
);
},
};
@@ -167,14 +191,14 @@ export class ToolService {
objectName: string,
parameters: Record<string, unknown>,
workspaceId: string,
roleId: string,
rolePermissionConfig: RolePermissionConfig,
) {
try {
const repository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace(
workspaceId,
objectName,
{ roleId },
rolePermissionConfig,
);
const { filter } = parameters;
@@ -28,7 +28,8 @@ export class CreateRecordService {
) {}
async execute(params: CreateRecordParams): Promise<ToolOutput> {
const { objectName, objectRecord, workspaceId, roleId } = params;
const { objectName, objectRecord, workspaceId, rolePermissionConfig } =
params;
if (!workspaceId) {
return {
@@ -43,7 +44,7 @@ export class CreateRecordService {
await this.twentyORMGlobalManager.getRepositoryForWorkspace(
workspaceId,
objectName,
roleId ? { roleId } : { shouldBypassPermissionChecks: true },
rolePermissionConfig,
);
const { objectMetadataItemWithFieldsMaps } =
@@ -27,7 +27,7 @@ export class DeleteRecordService {
objectName,
objectRecordId,
workspaceId,
roleId,
rolePermissionConfig,
soft = true,
} = params;
@@ -52,7 +52,7 @@ export class DeleteRecordService {
await this.twentyORMGlobalManager.getRepositoryForWorkspace(
workspaceId,
objectName,
roleId ? { roleId } : { shouldBypassPermissionChecks: true },
rolePermissionConfig,
);
const { objectMetadataItemWithFieldsMaps } =
@@ -37,7 +37,7 @@ export class FindRecordsService {
limit,
offset = 0,
workspaceId,
roleId,
rolePermissionConfig,
} = params;
if (!workspaceId) {
@@ -53,7 +53,7 @@ export class FindRecordsService {
await this.twentyORMGlobalManager.getRepositoryForWorkspace(
workspaceId,
objectName,
roleId ? { roleId } : { shouldBypassPermissionChecks: true },
rolePermissionConfig,
);
const { objectMetadataItemWithFieldsMaps, objectMetadataMaps } =
@@ -32,7 +32,7 @@ export class UpdateRecordService {
objectRecord,
fieldsToUpdate,
workspaceId,
roleId,
rolePermissionConfig,
} = params;
if (!workspaceId) {
@@ -56,7 +56,7 @@ export class UpdateRecordService {
await this.twentyORMGlobalManager.getRepositoryForWorkspace(
workspaceId,
objectName,
roleId ? { roleId } : { shouldBypassPermissionChecks: true },
rolePermissionConfig,
);
const previousObjectRecord = await repository.findOne({
@@ -1,10 +1,11 @@
import { type ObjectRecordProperties } from 'src/engine/core-modules/record-crud/types/object-record-properties.type';
import { type ActorMetadata } from 'src/engine/metadata-modules/field-metadata/composite-types/actor.composite-type';
import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config';
export type CreateRecordParams = {
objectName: string;
objectRecord: ObjectRecordProperties;
workspaceId: string;
roleId?: string;
rolePermissionConfig?: RolePermissionConfig;
createdBy?: ActorMetadata;
};
@@ -1,7 +1,9 @@
import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config';
export type DeleteRecordParams = {
objectName: string;
objectRecordId: string;
workspaceId: string;
roleId?: string;
rolePermissionConfig?: RolePermissionConfig;
soft?: boolean;
};
@@ -3,6 +3,8 @@ import {
type ObjectRecordOrderBy,
} from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config';
export type FindRecordsParams = {
objectName: string;
filter?:
@@ -14,5 +16,5 @@ export type FindRecordsParams = {
limit?: number;
offset?: number;
workspaceId: string;
roleId?: string;
rolePermissionConfig?: RolePermissionConfig;
};
@@ -1,4 +1,5 @@
import { type ObjectRecordProperties } from 'src/engine/core-modules/record-crud/types/object-record-properties.type';
import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config';
export type UpdateRecordParams = {
objectName: string;
@@ -6,5 +7,5 @@ export type UpdateRecordParams = {
objectRecord: ObjectRecordProperties;
fieldsToUpdate?: string[];
workspaceId: string;
roleId?: string;
rolePermissionConfig?: RolePermissionConfig;
};
@@ -1,5 +1,4 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import {
convertToModelMessages,
@@ -13,15 +12,15 @@ import {
} from 'ai';
import { AppPath } from 'twenty-shared/types';
import { getAppPath } from 'twenty-shared/utils';
import { In, Repository } from 'typeorm';
import { In } from 'typeorm';
import { getAllSelectableFields } from 'src/engine/api/utils/get-all-selectable-fields.utils';
import { AIBillingService } from 'src/engine/core-modules/ai/services/ai-billing.service';
import { AiModelRegistryService } from 'src/engine/core-modules/ai/services/ai-model-registry.service';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
import { FileService } from 'src/engine/core-modules/file/services/file.service';
import { type Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { AgentHandoffToolService } from 'src/engine/metadata-modules/agent/agent-handoff-tool.service';
import { AgentService } from 'src/engine/metadata-modules/agent/agent.service';
import { AGENT_CONFIG } from 'src/engine/metadata-modules/agent/constants/agent-config.const';
import { AGENT_SYSTEM_PROMPTS } from 'src/engine/metadata-modules/agent/constants/agent-system-prompts.const';
import { AgentActorContextService } from 'src/engine/metadata-modules/agent/services/agent-actor-context.service';
@@ -48,7 +47,6 @@ export class AgentExecutionService implements AgentExecutionContext {
constructor(
private readonly agentHandoffToolService: AgentHandoffToolService,
private readonly fileService: FileService,
private readonly domainManagerService: DomainManagerService,
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
private readonly workspacePermissionsCacheService: WorkspacePermissionsCacheService,
@@ -57,8 +55,7 @@ export class AgentExecutionService implements AgentExecutionContext {
private readonly agentModelConfigService: AgentModelConfigService,
private readonly aiBillingService: AIBillingService,
private readonly agentActorContextService: AgentActorContextService,
@InjectRepository(AgentEntity)
private readonly agentRepository: Repository<AgentEntity>,
private readonly agentService: AgentService,
) {}
async prepareAIRequestConfig({
@@ -66,14 +63,14 @@ export class AgentExecutionService implements AgentExecutionContext {
system,
agent,
actorContext,
roleIdOverride,
roleIds,
excludeHandoffTools = false,
}: {
system: string;
agent: AgentEntity | null;
messages: UIMessage<unknown, UIDataTypes, UITools>[];
actorContext?: ActorMetadata;
roleIdOverride?: string;
roleIds?: string[];
excludeHandoffTools?: boolean;
}) {
try {
@@ -95,7 +92,7 @@ export class AgentExecutionService implements AgentExecutionContext {
agent.id,
agent.workspaceId,
actorContext,
roleIdOverride,
roleIds,
);
let handoffTools = {};
@@ -193,8 +190,7 @@ export class AgentExecutionService implements AgentExecutionContext {
const repository = workspaceDataSource.getRepository(
recordsWithObjectMetadataNameSingular.objectMetadataNameSingular,
false,
roleId,
{ unionOf: [roleId] },
);
const restrictedFields =
@@ -254,9 +250,7 @@ export class AgentExecutionService implements AgentExecutionContext {
recordIdsByObjectMetadataNameSingular: RecordIdsByObjectMetadataNameSingularType;
}) {
try {
const agent = await this.agentRepository.findOneOrFail({
where: { id: agentId },
});
const agent = await this.agentService.findOneAgent(agentId, workspace.id);
let contextString = '';
@@ -271,7 +265,7 @@ export class AgentExecutionService implements AgentExecutionContext {
}
const { actorContext, roleId } =
await this.agentActorContextService.buildUserActorContext(
await this.agentActorContextService.buildUserAndAgentActorContext(
userWorkspaceId,
workspace.id,
);
@@ -281,7 +275,7 @@ export class AgentExecutionService implements AgentExecutionContext {
agent,
messages,
actorContext,
roleIdOverride: roleId,
roleIds: [roleId, ...(agent?.roleId ? [agent?.roleId] : [])],
});
this.logger.log(
@@ -6,7 +6,6 @@ import { Repository } from 'typeorm';
import { ToolAdapterService } from 'src/engine/core-modules/ai/services/tool-adapter.service';
import { ToolService } from 'src/engine/core-modules/ai/services/tool.service';
import { AgentService } from 'src/engine/metadata-modules/agent/agent.service';
import { type ActorMetadata } from 'src/engine/metadata-modules/field-metadata/composite-types/actor.composite-type';
import { PermissionFlagType } from 'src/engine/metadata-modules/permissions/constants/permission-flag-type.constants';
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
@@ -24,58 +23,43 @@ export class AgentToolGeneratorService {
private readonly toolService: ToolService,
private readonly workflowToolService: WorkflowToolService,
private readonly permissionsService: PermissionsService,
private readonly agentService: AgentService,
) {}
async generateToolsForAgent(
agentId: string,
workspaceId: string,
actorContext?: ActorMetadata,
roleIdOverride?: string,
roleIds?: string[],
): Promise<ToolSet> {
let tools: ToolSet = {};
try {
const agent = await this.agentService.findOneAgent(agentId, workspaceId);
const actionTools = await this.toolAdapterService.getTools();
tools = { ...actionTools };
const effectiveRoleId = roleIdOverride || agent.roleId;
if (!effectiveRoleId) {
return tools;
}
const role = await this.roleRepository.findOne({
where: {
id: effectiveRoleId,
workspaceId,
},
relations: ['permissionFlags'],
});
if (!role) {
if (!roleIds) {
return tools;
}
const hasWorkflowPermission =
this.permissionsService.checkRolePermissions(
role,
await this.permissionsService.checkRolesPermissions(
{ intersectionOf: roleIds },
workspaceId,
PermissionFlagType.WORKFLOWS,
);
if (hasWorkflowPermission) {
const workflowTools = this.workflowToolService.generateWorkflowTools(
workspaceId,
effectiveRoleId,
{ intersectionOf: roleIds },
);
tools = { ...tools, ...workflowTools };
}
const databaseTools = await this.toolService.listTools(
effectiveRoleId,
{ intersectionOf: roleIds },
workspaceId,
actorContext,
);
@@ -83,7 +67,7 @@ export class AgentToolGeneratorService {
tools = { ...tools, ...databaseTools };
const roleActionTools = await this.toolAdapterService.getTools(
effectiveRoleId,
{ intersectionOf: roleIds },
workspaceId,
);
@@ -12,7 +12,7 @@ import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.
export type AgentActorContext = {
actorContext: ActorMetadata;
roleId: string | undefined;
roleId: string;
};
@Injectable()
@@ -23,7 +23,7 @@ export class AgentActorContextService {
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
) {}
async buildUserActorContext(
async buildUserAndAgentActorContext(
userWorkspaceId: string,
workspaceId: string,
): Promise<AgentActorContext> {
@@ -61,11 +61,21 @@ export class AgentActorContextService {
workspaceId,
});
if (!roleId) {
throw new AgentException(
'User role not found',
AgentExceptionCode.AGENT_EXECUTION_FAILED,
);
}
const actorContext = buildCreatedByFromFullNameMetadata({
fullNameMetadata: workspaceMember.name,
workspaceMemberId: workspaceMember.id,
});
return { actorContext, roleId };
return {
actorContext,
roleId,
};
}
}
@@ -1,7 +1,7 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { type ObjectsPermissionsByRoleIdDeprecated } from 'twenty-shared/types';
import { type ObjectsPermissionsByRoleId } from 'twenty-shared/types';
import { In, type Repository } from 'typeorm';
import {
@@ -53,13 +53,9 @@ describe('FieldPermissionService', () => {
isEditable: true,
} as RoleEntity;
const mockRolesPermissions: ObjectsPermissionsByRoleIdDeprecated = {
const mockRolesPermissions: ObjectsPermissionsByRoleId = {
[testRoleId]: {
[testObjectMetadataId]: {
canRead: true,
canUpdate: true,
canSoftDelete: false,
canDestroy: false,
canReadObjectRecords: true,
canUpdateObjectRecords: true,
canSoftDeleteObjectRecords: false,
@@ -67,10 +63,6 @@ describe('FieldPermissionService', () => {
restrictedFields: {},
},
[fieldRelationMock.objectMetadataId]: {
canRead: true,
canUpdate: true,
canSoftDelete: false,
canDestroy: false,
canReadObjectRecords: true,
canUpdateObjectRecords: true,
canSoftDeleteObjectRecords: false,
@@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { msg } from '@lingui/core/macro';
import { type ObjectsPermissionsByRoleIdDeprecated } from 'twenty-shared/types';
import { type ObjectsPermissionsByRoleId } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { In, Repository } from 'typeorm';
@@ -183,7 +183,7 @@ export class FieldPermissionService {
allFieldPermissions: UpsertFieldPermissionsInput['fieldPermissions'];
fieldPermission: UpsertFieldPermissionsInput['fieldPermissions'][0];
objectMetadataMapsById: ObjectMetadataMaps['byId'];
rolesPermissions: ObjectsPermissionsByRoleIdDeprecated;
rolesPermissions: ObjectsPermissionsByRoleId;
role: RoleEntity;
}) {
const duplicateFieldPermissions = allFieldPermissions.filter(
@@ -3,7 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import { msg } from '@lingui/core/macro';
import { isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import { In, Repository } from 'typeorm';
import { ApiKeyRoleService } from 'src/engine/core-modules/api-key/api-key-role.service';
import { PermissionFlagType } from 'src/engine/metadata-modules/permissions/constants/permission-flag-type.constants';
@@ -17,6 +17,7 @@ import { type UserWorkspacePermissions } from 'src/engine/metadata-modules/permi
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service';
import { WorkspacePermissionsCacheService } from 'src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache.service';
import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config';
@Injectable()
export class PermissionsService {
@@ -188,30 +189,101 @@ export class PermissionsService {
);
}
private async getRolesFromPermissionConfig(
rolePermissionConfig: RolePermissionConfig,
workspaceId: string,
relations: string[] = [],
): Promise<{ roles: RoleEntity[]; useIntersection: boolean } | null> {
if ('shouldBypassPermissionChecks' in rolePermissionConfig) {
return null;
}
let roleIds: string[] = [];
let useIntersection = false;
if ('intersectionOf' in rolePermissionConfig) {
roleIds = rolePermissionConfig.intersectionOf;
useIntersection = true;
} else if ('unionOf' in rolePermissionConfig) {
roleIds = rolePermissionConfig.unionOf;
useIntersection = false;
}
if (roleIds.length === 0) {
throw new Error('No role IDs provided');
}
const roles = await this.roleRepository.find({
where: { id: In(roleIds), workspaceId },
relations,
});
if (roles.length !== roleIds.length) {
throw new Error('Some roles not found');
}
return { roles, useIntersection };
}
public async checkRolesPermissions(
rolePermissionConfig: RolePermissionConfig,
workspaceId: string,
setting: PermissionFlagType,
): Promise<boolean> {
try {
const result = await this.getRolesFromPermissionConfig(
rolePermissionConfig,
workspaceId,
['permissionFlags'],
);
if (result === null) {
return true;
}
const { roles, useIntersection } = result;
return useIntersection
? roles.every((role) => this.checkRolePermissions(role, setting))
: roles.some((role) => this.checkRolePermissions(role, setting));
} catch {
return false;
}
}
public async hasToolPermission(
roleId: string,
rolePermissionConfig: RolePermissionConfig,
workspaceId: string,
flag: PermissionFlagType,
): Promise<boolean> {
try {
const role = await this.roleRepository.findOne({
where: { id: roleId, workspaceId },
relations: ['permissionFlags'],
});
const result = await this.getRolesFromPermissionConfig(
rolePermissionConfig,
workspaceId,
['permissionFlags'],
);
if (!role) {
return false;
}
if (role.canAccessAllTools === true) {
if (result === null) {
return true;
}
const permissionFlags = role.permissionFlags ?? [];
const { roles, useIntersection } = result;
return permissionFlags.some(
(permissionFlag) => permissionFlag.flag === flag,
);
const checkRoleHasPermission = (role: RoleEntity) => {
if (role.canAccessAllTools === true) {
return true;
}
const permissionFlags = role.permissionFlags ?? [];
return permissionFlags.some(
(permissionFlag) => permissionFlag.flag === flag,
);
};
return useIntersection
? roles.every(checkRoleHasPermission)
: roles.some(checkRoleHasPermission);
} catch {
return false;
}
@@ -1,6 +1,6 @@
import { Injectable, Logger } from '@nestjs/common';
import { type ObjectsPermissionsByRoleIdDeprecated } from 'twenty-shared/types';
import { type ObjectsPermissionsByRoleId } from 'twenty-shared/types';
import { v4 } from 'uuid';
import { InjectCacheStorage } from 'src/engine/core-modules/cache-storage/decorators/cache-storage.decorator';
@@ -34,12 +34,12 @@ export class WorkspacePermissionsCacheStorageService {
async setRolesPermissions(
workspaceId: string,
permissions: ObjectsPermissionsByRoleIdDeprecated,
permissions: ObjectsPermissionsByRoleId,
): Promise<{
newRolesPermissionsVersion: string;
}> {
const [, newRolesPermissionsVersion] = await Promise.all([
this.cacheStorageService.set<ObjectsPermissionsByRoleIdDeprecated>(
this.cacheStorageService.set<ObjectsPermissionsByRoleId>(
`${WORKSPACE_CACHE_KEYS.MetadataPermissionsRolesPermissions}:${workspaceId}`,
permissions,
TTL_INFINITE,
@@ -52,8 +52,8 @@ export class WorkspacePermissionsCacheStorageService {
getRolesPermissions(
workspaceId: string,
): Promise<ObjectsPermissionsByRoleIdDeprecated | undefined> {
return this.cacheStorageService.get<ObjectsPermissionsByRoleIdDeprecated>(
): Promise<ObjectsPermissionsByRoleId | undefined> {
return this.cacheStorageService.get<ObjectsPermissionsByRoleId>(
`${WORKSPACE_CACHE_KEYS.MetadataPermissionsRolesPermissions}:${workspaceId}`,
);
}
@@ -3,9 +3,8 @@ import { InjectRepository } from '@nestjs/typeorm';
import { Record } from 'cloudflare/core';
import {
ObjectsPermissions,
type ObjectsPermissionsByRoleIdDeprecated,
type ObjectsPermissionsDeprecated,
type ObjectsPermissions,
type ObjectsPermissionsByRoleId,
type RestrictedFieldsPermissions,
} from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
@@ -48,7 +47,7 @@ export class WorkspacePermissionsCacheService {
private readonly workspacePermissionsCacheStorageService: WorkspacePermissionsCacheStorageService,
private readonly getRolesPermissionsFromCacheWithRecomputeService: GetDataFromCacheWithRecomputeService<
string,
ObjectsPermissionsByRoleIdDeprecated
ObjectsPermissionsByRoleId
>,
private readonly getUserWorkspaceRoleMapFromCacheWithRecomputeService: GetDataFromCacheWithRecomputeService<
string,
@@ -67,9 +66,7 @@ export class WorkspacePermissionsCacheService {
workspaceId: string;
roleIds?: string[];
}): Promise<void> {
let currentRolesPermissions:
| ObjectsPermissionsByRoleIdDeprecated
| undefined;
let currentRolesPermissions: ObjectsPermissionsByRoleId | undefined;
if (roleIds) {
currentRolesPermissions =
@@ -124,7 +121,7 @@ export class WorkspacePermissionsCacheService {
workspaceId,
}: {
workspaceId: string;
}): Promise<CacheResult<string, ObjectsPermissionsByRoleIdDeprecated>> {
}): Promise<CacheResult<string, ObjectsPermissionsByRoleId>> {
return this.getRolesPermissionsFromCacheWithRecomputeService.getFromCacheWithRecompute(
{
workspaceId,
@@ -194,7 +191,7 @@ export class WorkspacePermissionsCacheService {
}: {
workspaceId: string;
roleIds?: string[];
}): Promise<ObjectsPermissionsByRoleIdDeprecated> {
}): Promise<ObjectsPermissionsByRoleId> {
let roles: RoleEntity[] = [];
roles = await this.roleRepository.find({
@@ -208,11 +205,10 @@ export class WorkspacePermissionsCacheService {
const workspaceObjectMetadataCollection =
await this.getWorkspaceObjectMetadataCollection(workspaceId);
const permissionsByRoleId: ObjectsPermissionsByRoleIdDeprecated = {};
const permissionsByRoleId: ObjectsPermissionsByRoleId = {};
for (const role of roles) {
const objectRecordsPermissions: ObjectsPermissionsDeprecated &
ObjectsPermissions = {};
const objectRecordsPermissions: ObjectsPermissions = {};
for (const objectMetadata of workspaceObjectMetadataCollection) {
const { id: objectMetadataId, isSystem, standardId } = objectMetadata;
@@ -288,10 +284,6 @@ export class WorkspacePermissionsCacheService {
}
objectRecordsPermissions[objectMetadataId] = {
canRead,
canUpdate,
canSoftDelete,
canDestroy,
canReadObjectRecords: canRead,
canUpdateObjectRecords: canUpdate,
canSoftDeleteObjectRecords: canSoftDelete,
@@ -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;
};
@@ -49,10 +49,14 @@ export class WorkflowExecutionContextService {
});
}
const rolePermissionConfig = roleId
? { unionOf: [roleId] }
: { shouldBypassPermissionChecks: true as const };
return {
isActingOnBehalfOfUser,
initiator: workflowRun.createdBy,
roleId,
rolePermissionConfig,
};
}
}
@@ -1,7 +1,8 @@
import { type ActorMetadata } from 'src/engine/metadata-modules/field-metadata/composite-types/actor.composite-type';
import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config';
export type WorkflowExecutionContext = {
isActingOnBehalfOfUser: boolean;
initiator: ActorMetadata;
roleId?: string;
rolePermissionConfig: RolePermissionConfig;
};
@@ -85,7 +85,7 @@ export class AiAgentWorkflowAction implements WorkflowAction {
actorContext: executionContext.isActingOnBehalfOfUser
? executionContext.initiator
: undefined,
roleId: executionContext.roleId,
rolePermissionConfig: executionContext.rolePermissionConfig,
},
);
@@ -18,6 +18,7 @@ import { AGENT_SYSTEM_PROMPTS } from 'src/engine/metadata-modules/agent/constant
import { convertOutputSchemaToZod } from 'src/engine/metadata-modules/agent/utils/convert-output-schema-to-zod';
import { type ActorMetadata } from 'src/engine/metadata-modules/field-metadata/composite-types/actor.composite-type';
import { RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity';
import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config';
import { OutputSchema } from 'src/modules/workflow/workflow-builder/workflow-schema/types/output-schema.type';
@Injectable()
@@ -35,7 +36,7 @@ export class AiAgentExecutorService {
agentId: string,
workspaceId: string,
actorContext?: ActorMetadata,
roleIdOverride?: string,
rolePermissionConfig?: RolePermissionConfig,
): Promise<ToolSet> {
const roleTarget = await this.roleTargetsRepository.findOne({
where: {
@@ -45,21 +46,33 @@ export class AiAgentExecutorService {
select: ['roleId'],
});
const effectiveRoleId = roleIdOverride || roleTarget?.roleId;
const agentRoleId = roleTarget?.roleId;
if (!effectiveRoleId) {
const actionTools = await this.toolAdapterService.getTools();
if (!rolePermissionConfig && !agentRoleId) {
return await this.toolAdapterService.getTools();
}
return { ...actionTools };
let effectiveRoleContext: RolePermissionConfig;
if (
rolePermissionConfig &&
('intersectionOf' in rolePermissionConfig ||
'unionOf' in rolePermissionConfig)
) {
effectiveRoleContext = rolePermissionConfig;
} else if (agentRoleId) {
effectiveRoleContext = { unionOf: [agentRoleId] };
} else {
return await this.toolAdapterService.getTools();
}
const actionTools = await this.toolAdapterService.getTools(
effectiveRoleId,
effectiveRoleContext,
workspaceId,
);
const databaseTools = await this.toolService.listTools(
effectiveRoleId,
effectiveRoleContext,
workspaceId,
actorContext,
);
@@ -75,20 +88,25 @@ export class AiAgentExecutorService {
schema,
userPrompt,
actorContext,
roleId,
rolePermissionConfig,
}: {
agent: AgentEntity | null;
schema: OutputSchema;
userPrompt: string;
actorContext?: ActorMetadata;
roleId?: string;
rolePermissionConfig?: RolePermissionConfig;
}): Promise<AgentExecutionResult> {
try {
const registeredModel =
await this.aiModelRegistryService.resolveModelForAgent(agent);
const tools = agent
? await this.getTools(agent.id, agent.workspaceId, actorContext, roleId)
? await this.getTools(
agent.id,
agent.workspaceId,
actorContext,
rolePermissionConfig,
)
: {};
this.logger.log(`Generated ${Object.keys(tools).length} tools for agent`);
@@ -64,7 +64,7 @@ export class CreateRecordWorkflowAction implements WorkflowAction {
objectRecord: workflowActionInput.objectRecord,
workspaceId,
createdBy,
roleId: executionContext.roleId,
rolePermissionConfig: executionContext.rolePermissionConfig,
});
if (!toolOutput.success) {
@@ -79,7 +79,7 @@ export class DeleteRecordWorkflowAction implements WorkflowAction {
objectName: workflowActionInput.objectName,
objectRecordId: workflowActionInput.objectRecordId,
workspaceId,
roleId: executionContext.roleId,
rolePermissionConfig: executionContext.rolePermissionConfig,
soft: true,
});
@@ -70,7 +70,7 @@ export class FindRecordsWorkflowAction implements WorkflowAction {
orderBy: workflowActionInput.orderBy,
limit: workflowActionInput.limit,
workspaceId,
roleId: executionContext.roleId,
rolePermissionConfig: executionContext.rolePermissionConfig,
});
if (!toolOutput.success) {
@@ -81,7 +81,7 @@ export class UpdateRecordWorkflowAction implements WorkflowAction {
objectRecord: workflowActionInput.objectRecord,
fieldsToUpdate: workflowActionInput.fieldsToUpdate,
workspaceId,
roleId: executionContext.roleId,
rolePermissionConfig: executionContext.rolePermissionConfig,
});
if (!toolOutput.success) {
@@ -8,6 +8,7 @@ import type { CreateWorkflowVersionStepInput } from 'src/engine/core-modules/wor
import type { UpdateWorkflowVersionPositionsInput } from 'src/engine/core-modules/workflow/dtos/update-workflow-version-positions-input.dto';
import type { UpdateWorkflowVersionStepInput } from 'src/engine/core-modules/workflow/dtos/update-workflow-version-step-input.dto';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config';
import { WorkflowVersionStatus } from 'src/modules/workflow/common/standard-objects/workflow-version.workspace-entity';
import { WorkflowStatus } from 'src/modules/workflow/common/standard-objects/workflow.workspace-entity';
import { WorkflowSchemaWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-schema/workflow-schema.workspace-service';
@@ -43,7 +44,10 @@ export class WorkflowToolWorkspaceService {
private readonly recordPositionService: RecordPositionService,
) {}
generateWorkflowTools(workspaceId: string, roleId: string): ToolSet {
generateWorkflowTools(
workspaceId: string,
rolePermissionConfig: RolePermissionConfig,
): ToolSet {
const tools: ToolSet = {};
tools.create_complete_workflow = {
@@ -88,7 +92,7 @@ This is the most efficient way for AI to create workflows as it handles all the
const workflowId = await this.createWorkflow({
workspaceId,
name: parameters.name,
roleId,
rolePermissionConfig,
});
const workflowVersionId = await this.createWorkflowVersion({
@@ -96,7 +100,7 @@ This is the most efficient way for AI to create workflows as it handles all the
workflowId,
trigger: parameters.trigger,
steps: parameters.steps,
roleId,
rolePermissionConfig,
});
if (parameters.stepPositions && parameters.stepPositions.length > 0) {
@@ -132,7 +136,7 @@ This is the most efficient way for AI to create workflows as it handles all the
workspaceId,
workflowId,
workflowVersionId,
roleId,
rolePermissionConfig,
});
}
@@ -400,17 +404,17 @@ This is the most efficient way for AI to create workflows as it handles all the
private async createWorkflow({
workspaceId,
name,
roleId,
rolePermissionConfig,
}: {
workspaceId: string;
name: string;
roleId: string;
rolePermissionConfig: RolePermissionConfig;
}): Promise<string> {
const workflowRepository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace(
workspaceId,
'workflow',
{ roleId },
rolePermissionConfig,
);
const workflowPosition =
@@ -440,19 +444,19 @@ This is the most efficient way for AI to create workflows as it handles all the
workflowId,
trigger,
steps,
roleId,
rolePermissionConfig,
}: {
workspaceId: string;
workflowId: string;
trigger: WorkflowTrigger;
steps: WorkflowAction[];
roleId: string;
rolePermissionConfig: RolePermissionConfig;
}): Promise<string> {
const workflowVersionRepository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace(
workspaceId,
'workflowVersion',
{ roleId },
rolePermissionConfig,
);
const versionPosition =
@@ -484,18 +488,18 @@ This is the most efficient way for AI to create workflows as it handles all the
workspaceId,
workflowId,
workflowVersionId,
roleId,
rolePermissionConfig,
}: {
workspaceId: string;
workflowId: string;
workflowVersionId: string;
roleId: string;
rolePermissionConfig: RolePermissionConfig;
}) {
const workflowRepository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace(
workspaceId,
'workflow',
{ roleId },
rolePermissionConfig,
);
await workflowRepository.update(workflowId, {
@@ -22,11 +22,8 @@ describe('AgentToolGeneratorService Integration', () => {
};
jest
.spyOn(context.agentService, 'findOneAgent')
.mockResolvedValue(context.testAgent as any);
jest
.spyOn(context.roleRepository, 'findOne')
.mockResolvedValue(roleWithFullPermissions);
.spyOn(context.roleRepository, 'find')
.mockResolvedValue([roleWithFullPermissions]);
jest
.spyOn(
context.workspacePermissionsCacheService,
@@ -36,10 +33,6 @@ describe('AgentToolGeneratorService Integration', () => {
data: {
[context.testRoleId]: {
[context.testObjectMetadata.id]: {
canRead: true,
canUpdate: true,
canSoftDelete: true,
canDestroy: true,
canReadObjectRecords: true,
canUpdateObjectRecords: true,
canSoftDeleteObjectRecords: true,
@@ -57,6 +50,8 @@ describe('AgentToolGeneratorService Integration', () => {
const tools = await context.agentToolService.generateToolsForAgent(
context.testAgentId,
context.testWorkspaceId,
undefined,
[context.testRoleId],
);
expect(tools).toBeDefined();
@@ -72,11 +67,8 @@ describe('AgentToolGeneratorService Integration', () => {
it('should generate read-only tools for agent with read permissions only', async () => {
jest
.spyOn(context.agentService, 'findOneAgent')
.mockResolvedValue(context.testAgent as any);
jest
.spyOn(context.roleRepository, 'findOne')
.mockResolvedValue(context.testRole);
.spyOn(context.roleRepository, 'find')
.mockResolvedValue([context.testRole]);
jest
.spyOn(
context.workspacePermissionsCacheService,
@@ -86,10 +78,6 @@ describe('AgentToolGeneratorService Integration', () => {
data: {
[context.testRoleId]: {
[context.testObjectMetadata.id]: {
canRead: true,
canUpdate: false,
canSoftDelete: false,
canDestroy: false,
canReadObjectRecords: true,
canUpdateObjectRecords: false,
canSoftDeleteObjectRecords: false,
@@ -107,6 +95,8 @@ describe('AgentToolGeneratorService Integration', () => {
const tools = await context.agentToolService.generateToolsForAgent(
context.testAgentId,
context.testWorkspaceId,
undefined,
[context.testRoleId],
);
expect(tools).toBeDefined();
@@ -118,12 +108,6 @@ describe('AgentToolGeneratorService Integration', () => {
});
it('should return only http request tool for agent without role', async () => {
const agentWithoutRole = { ...context.testAgent, roleId: null };
jest
.spyOn(context.agentService, 'findOneAgent')
.mockResolvedValue(agentWithoutRole as any);
const tools = await context.agentToolService.generateToolsForAgent(
context.testAgentId,
context.testWorkspaceId,
@@ -141,11 +125,8 @@ describe('AgentToolGeneratorService Integration', () => {
};
jest
.spyOn(context.agentService, 'findOneAgent')
.mockResolvedValue(context.testAgent as any);
jest
.spyOn(context.roleRepository, 'findOne')
.mockResolvedValue(context.testRole);
.spyOn(context.roleRepository, 'find')
.mockResolvedValue([context.testRole]);
jest
.spyOn(
context.workspacePermissionsCacheService,
@@ -155,10 +136,6 @@ describe('AgentToolGeneratorService Integration', () => {
data: {
[context.testRoleId]: {
[workflowObject.id]: {
canRead: true,
canUpdate: true,
canSoftDelete: true,
canDestroy: false,
canReadObjectRecords: true,
canUpdateObjectRecords: true,
canSoftDeleteObjectRecords: true,
@@ -176,6 +153,8 @@ describe('AgentToolGeneratorService Integration', () => {
const tools = await context.agentToolService.generateToolsForAgent(
context.testAgentId,
context.testWorkspaceId,
undefined,
[context.testRoleId],
);
expect(Object.keys(tools)).toHaveLength(7);
@@ -65,6 +65,7 @@ export const createAgentToolTestModule =
provide: getRepositoryToken(RoleEntity),
useValue: {
findOne: jest.fn(),
find: jest.fn(),
},
},
{
@@ -161,6 +162,7 @@ export const createAgentToolTestModule =
useValue: {
hasToolPermission: jest.fn(),
checkRolePermissions: jest.fn().mockReturnValue(true),
checkRolesPermissions: jest.fn().mockResolvedValue(true),
},
},
{
@@ -341,10 +343,6 @@ export const setupBasicPermissions = (context: AgentToolTestContext) => {
data: {
[context.testRoleId]: {
[context.testObjectMetadata.id]: {
canRead: true,
canUpdate: true,
canSoftDelete: true,
canDestroy: false,
canReadObjectRecords: true,
canUpdateObjectRecords: true,
canSoftDeleteObjectRecords: true,
@@ -1,10 +0,0 @@
import { type RestrictedFieldsPermissions } from './RestrictedFieldsPermissions';
// TODO: DEPRECATE THIS
export type ObjectPermissionsDeprecated = {
canRead: boolean;
canUpdate: boolean;
canSoftDelete: boolean;
canDestroy: boolean;
restrictedFields: RestrictedFieldsPermissions;
};
@@ -1,10 +0,0 @@
import { type ObjectsPermissions } from '@/types/ObjectsPermissions';
import { type ObjectsPermissionsDeprecated } from './ObjectsPermissionsDeprecated';
type RoleId = string;
// TODO: DEPRECATE THIS
export type ObjectsPermissionsByRoleIdDeprecated = Record<
RoleId,
ObjectsPermissionsDeprecated & ObjectsPermissions
>;
@@ -1,9 +0,0 @@
import { type ObjectPermissionsDeprecated } from './ObjectPermissionsDeprecated';
type ObjectMetadataId = string;
// TODO: DEPRECATE THIS
export type ObjectsPermissionsDeprecated = Record<
ObjectMetadataId,
ObjectPermissionsDeprecated
>;
@@ -34,7 +34,6 @@ export type { NonNullableRequired } from './NonNullableRequired';
export type { Nullable } from './Nullable';
export type { NullablePartial } from './NullablePartial';
export type { ObjectPermissions } from './ObjectPermissions';
export type { ObjectPermissionsDeprecated } from './ObjectPermissionsDeprecated';
export type { ObjectRecord } from './ObjectRecord';
export type {
AggregateOrderByWithGroupByField,
@@ -47,8 +46,6 @@ export { OrderByDirection } from './ObjectRecordGroupBy';
export { ObjectRecordGroupByDateGranularity } from './ObjectRecordGroupByDateGranularity';
export type { ObjectsPermissions } from './ObjectsPermissions';
export type { ObjectsPermissionsByRoleId } from './ObjectsPermissionsByRoleId';
export type { ObjectsPermissionsByRoleIdDeprecated } from './ObjectsPermissionsByRoleIdDeprecated';
export type { ObjectsPermissionsDeprecated } from './ObjectsPermissionsDeprecated';
export type { PartialFieldMetadataItem } from './PartialFieldMetadataItem';
export type { PartialFieldMetadataItemOption } from './PartialFieldMetadataOption';
export { RecordFilterGroupLogicalOperator } from './RecordFilterGroupLogicalOperator';