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
@@ -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, {