feat: multi role permission intersection (#15150)

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

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

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

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

---------

Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
This commit is contained in:
Abdul Rahman
2025-10-19 13:00:05 +05:30
committed by GitHub
parent f6d133f285
commit 3bec43696f
75 changed files with 909 additions and 489 deletions
@@ -1,5 +1,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,
};
}
}