implement "acting on behalf of user" for workflows and agents (#15103)

## Summary
**Step 1 of 2:** Implements the "acting on behalf of user" concept for
workflows and agents to prevent permission escalation and maintain
proper audit trails.

## Problem
Previously, workflows and agents would bypass permissions regardless of
who initiated them, allowing users to escalate their privileges by
triggering workflows that performed actions they couldn't do directly.

## Solution

### For Workflows
Introduced `WorkflowExecutionContext` service that determines execution
mode:
- **Manual triggers/test button**: Uses user's roleId for permissions,
user's identity for `createdBy`
- **Automated triggers** (cron, database events, webhooks): Bypasses
permissions, uses workflow identity

### For Agents
**In Chat:**
- Always act on behalf of the user
- Use user's roleId for permission checks
- Use user's identity for `createdBy`


# Step 1 vs Step 2

###  Step 1 (This PR): Acting on Behalf Concept
- Introduced `isActingOnBehalfOfUser` boolean concept
- Single roleId used for permission checks (user's OR system bypass)
- `createdBy` field properly attributes actions to initiator
- Prevents permission escalation in user-initiated flows

### 🔜 Step 2 (Future): Multi-Role Permission Support
- Support role intersection: `{ intersection: ['roleA', 'roleB'] }`
- Support role union: `{ union: ['roleA', 'roleB', 'roleC'] }`
- Enable user+agent collaboration scenarios
- Update `WorkspaceEntityManager` and `WorkspaceDatasource` to handle
multiple roleIds

---------

Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
This commit is contained in:
Abdul Rahman
2025-10-18 02:26:28 +05:30
committed by GitHub
parent 434df8a94c
commit a3f9657b73
17 changed files with 276 additions and 26 deletions
@@ -0,0 +1,58 @@
import { Injectable } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import { UserWorkspaceService as UserService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
import { FieldActorSource } from 'src/engine/metadata-modules/field-metadata/composite-types/actor.composite-type';
import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service';
import { type WorkflowExecutionContext } from 'src/modules/workflow/workflow-executor/types/workflow-execution-context.type';
import { WorkflowRunWorkspaceService as WorkflowRunService } from 'src/modules/workflow/workflow-runner/workflow-run/workflow-run.workspace-service';
@Injectable()
export class WorkflowExecutionContextService {
constructor(
private readonly workflowRunService: WorkflowRunService,
private readonly userService: UserService,
private readonly userRoleService: UserRoleService,
) {}
async getExecutionContext(runInfo: {
workflowRunId: string;
workspaceId: string;
}): Promise<WorkflowExecutionContext> {
const workflowRun = await this.workflowRunService.getWorkflowRunOrFail({
workflowRunId: runInfo.workflowRunId,
workspaceId: runInfo.workspaceId,
});
const isActingOnBehalfOfUser =
workflowRun.createdBy.source === FieldActorSource.MANUAL &&
isDefined(workflowRun.createdBy.workspaceMemberId);
let roleId: string | undefined;
if (isActingOnBehalfOfUser) {
const workspaceMember = await this.userService.getWorkspaceMemberOrThrow({
workspaceMemberId: workflowRun.createdBy.workspaceMemberId!,
workspaceId: runInfo.workspaceId,
});
const userWorkspace =
await this.userService.getUserWorkspaceForUserOrThrow({
userId: workspaceMember.userId,
workspaceId: runInfo.workspaceId,
});
roleId = await this.userRoleService.getRoleIdForUserWorkspace({
userWorkspaceId: userWorkspace.id,
workspaceId: runInfo.workspaceId,
});
}
return {
isActingOnBehalfOfUser,
initiator: workflowRun.createdBy,
roleId,
};
}
}
@@ -0,0 +1,7 @@
import { type ActorMetadata } from 'src/engine/metadata-modules/field-metadata/composite-types/actor.composite-type';
export type WorkflowExecutionContext = {
isActingOnBehalfOfUser: boolean;
initiator: ActorMetadata;
roleId?: string;
};
@@ -2,21 +2,28 @@ import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AiModule } from 'src/engine/core-modules/ai/ai.module';
import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user-workspace.module';
import { AgentEntity } from 'src/engine/metadata-modules/agent/agent.entity';
import { RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity';
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.module';
import { ScopedWorkspaceContextFactory } from 'src/engine/twenty-orm/factories/scoped-workspace-context.factory';
import { WorkflowExecutionContextService } from 'src/modules/workflow/workflow-executor/services/workflow-execution-context.service';
import { AiAgentExecutorService } from 'src/modules/workflow/workflow-executor/workflow-actions/ai-agent/services/ai-agent-executor.service';
import { WorkflowRunModule } from 'src/modules/workflow/workflow-runner/workflow-run/workflow-run.module';
import { AiAgentWorkflowAction } from './ai-agent.workflow-action';
@Module({
imports: [
AiModule,
TypeOrmModule.forFeature([AgentEntity, RoleTargetsEntity, RoleEntity]),
TypeOrmModule.forFeature([AgentEntity, RoleTargetsEntity]),
WorkflowRunModule,
UserWorkspaceModule,
UserRoleModule,
],
providers: [
ScopedWorkspaceContextFactory,
WorkflowExecutionContextService,
AiAgentWorkflowAction,
AiAgentExecutorService,
],
@@ -16,6 +16,7 @@ import {
WorkflowStepExecutorException,
WorkflowStepExecutorExceptionCode,
} from 'src/modules/workflow/workflow-executor/exceptions/workflow-step-executor.exception';
import { WorkflowExecutionContextService } from 'src/modules/workflow/workflow-executor/services/workflow-execution-context.service';
import { type WorkflowActionInput } from 'src/modules/workflow/workflow-executor/types/workflow-action-input';
import { type WorkflowActionOutput } from 'src/modules/workflow/workflow-executor/types/workflow-action-output.type';
import { findStepOrThrow } from 'src/modules/workflow/workflow-executor/utils/find-step-or-throw.util';
@@ -28,6 +29,7 @@ export class AiAgentWorkflowAction implements WorkflowAction {
constructor(
private readonly aiAgentExecutionService: AiAgentExecutorService,
private readonly aiBillingService: AIBillingService,
private readonly workflowExecutionContextService: WorkflowExecutionContextService,
@InjectRepository(AgentEntity)
private readonly agentRepository: Repository<AgentEntity>,
) {}
@@ -36,6 +38,7 @@ export class AiAgentWorkflowAction implements WorkflowAction {
currentStepId,
steps,
context,
runInfo,
}: WorkflowActionInput): Promise<WorkflowActionOutput> {
const step = findStepOrThrow({
stepId: currentStepId,
@@ -71,11 +74,18 @@ export class AiAgentWorkflowAction implements WorkflowAction {
);
}
const executionContext =
await this.workflowExecutionContextService.getExecutionContext(runInfo);
const { result, usage } = await this.aiAgentExecutionService.executeAgent(
{
agent,
schema: step.settings.outputSchema,
userPrompt: resolveInput(prompt, context) as string,
actorContext: executionContext.isActingOnBehalfOfUser
? executionContext.initiator
: undefined,
roleId: executionContext.roleId,
},
);
@@ -16,8 +16,8 @@ import {
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 { 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 { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
import { OutputSchema } from 'src/modules/workflow/workflow-builder/workflow-schema/types/output-schema.type';
@Injectable()
@@ -28,14 +28,14 @@ export class AiAgentExecutorService {
private readonly toolAdapterService: ToolAdapterService,
@InjectRepository(RoleTargetsEntity)
private readonly roleTargetsRepository: Repository<RoleTargetsEntity>,
@InjectRepository(RoleEntity)
private readonly roleRepository: Repository<RoleEntity>,
private readonly toolService: ToolService,
) {}
private async getTools(
agentId: string,
workspaceId: string,
actorContext?: ActorMetadata,
roleIdOverride?: string,
): Promise<ToolSet> {
const roleTarget = await this.roleTargetsRepository.findOne({
where: {
@@ -45,27 +45,23 @@ export class AiAgentExecutorService {
select: ['roleId'],
});
const role = await this.roleRepository.findOne({
where: {
id: roleTarget?.roleId,
workspaceId,
},
});
const effectiveRoleId = roleIdOverride || roleTarget?.roleId;
if (!roleTarget?.roleId || !role) {
if (!effectiveRoleId) {
const actionTools = await this.toolAdapterService.getTools();
return { ...actionTools };
}
const actionTools = await this.toolAdapterService.getTools(
role.id,
effectiveRoleId,
workspaceId,
);
const databaseTools = await this.toolService.listTools(
role.id,
effectiveRoleId,
workspaceId,
actorContext,
);
return {
@@ -78,17 +74,21 @@ export class AiAgentExecutorService {
agent,
schema,
userPrompt,
actorContext,
roleId,
}: {
agent: AgentEntity | null;
schema: OutputSchema;
userPrompt: string;
actorContext?: ActorMetadata;
roleId?: string;
}): Promise<AgentExecutionResult> {
try {
const registeredModel =
await this.aiModelRegistryService.resolveModelForAgent(agent);
const tools = agent
? await this.getTools(agent.id, agent.workspaceId)
? await this.getTools(agent.id, agent.workspaceId, actorContext, roleId)
: {};
this.logger.log(`Generated ${Object.keys(tools).length} tools for agent`);
@@ -9,9 +9,15 @@ import {
RecordCrudExceptionCode,
} from 'src/engine/core-modules/record-crud/exceptions/record-crud.exception';
import { CreateRecordService } from 'src/engine/core-modules/record-crud/services/create-record.service';
import {
type ActorMetadata,
FieldActorSource,
} from 'src/engine/metadata-modules/field-metadata/composite-types/actor.composite-type';
import { ScopedWorkspaceContextFactory } from 'src/engine/twenty-orm/factories/scoped-workspace-context.factory';
import { WorkflowExecutionContextService } from 'src/modules/workflow/workflow-executor/services/workflow-execution-context.service';
import { type WorkflowActionInput } from 'src/modules/workflow/workflow-executor/types/workflow-action-input';
import { type WorkflowActionOutput } from 'src/modules/workflow/workflow-executor/types/workflow-action-output.type';
import { type WorkflowExecutionContext } from 'src/modules/workflow/workflow-executor/types/workflow-execution-context.type';
import { findStepOrThrow } from 'src/modules/workflow/workflow-executor/utils/find-step-or-throw.util';
import { type WorkflowCreateRecordActionInput } from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/types/workflow-record-crud-action-input.type';
@@ -20,12 +26,14 @@ export class CreateRecordWorkflowAction implements WorkflowAction {
constructor(
private readonly createRecordService: CreateRecordService,
private readonly scopedWorkspaceContextFactory: ScopedWorkspaceContextFactory,
private readonly workflowExecutionContextService: WorkflowExecutionContextService,
) {}
async execute({
currentStepId,
steps,
context,
runInfo,
}: WorkflowActionInput): Promise<WorkflowActionOutput> {
const step = findStepOrThrow({
steps,
@@ -46,10 +54,17 @@ export class CreateRecordWorkflowAction implements WorkflowAction {
context,
) as WorkflowCreateRecordActionInput;
const executionContext =
await this.workflowExecutionContextService.getExecutionContext(runInfo);
const createdBy = this.buildCreatedByActor(executionContext);
const toolOutput = await this.createRecordService.execute({
objectName: workflowActionInput.objectName,
objectRecord: workflowActionInput.objectRecord,
workspaceId,
createdBy,
roleId: executionContext.roleId,
});
if (!toolOutput.success) {
@@ -63,4 +78,19 @@ export class CreateRecordWorkflowAction implements WorkflowAction {
result: toolOutput.result,
};
}
private buildCreatedByActor(
executionContext: WorkflowExecutionContext,
): ActorMetadata {
if (executionContext.isActingOnBehalfOfUser) {
return executionContext.initiator;
}
return {
source: FieldActorSource.WORKFLOW,
name: 'Workflow',
workspaceMemberId: null,
context: {},
};
}
}
@@ -14,6 +14,7 @@ import {
WorkflowStepExecutorException,
WorkflowStepExecutorExceptionCode,
} from 'src/modules/workflow/workflow-executor/exceptions/workflow-step-executor.exception';
import { WorkflowExecutionContextService } from 'src/modules/workflow/workflow-executor/services/workflow-execution-context.service';
import { type WorkflowActionInput } from 'src/modules/workflow/workflow-executor/types/workflow-action-input';
import { type WorkflowActionOutput } from 'src/modules/workflow/workflow-executor/types/workflow-action-output.type';
import { findStepOrThrow } from 'src/modules/workflow/workflow-executor/utils/find-step-or-throw.util';
@@ -25,12 +26,14 @@ export class DeleteRecordWorkflowAction implements WorkflowAction {
constructor(
private readonly deleteRecordService: DeleteRecordService,
private readonly scopedWorkspaceContextFactory: ScopedWorkspaceContextFactory,
private readonly workflowExecutionContextService: WorkflowExecutionContextService,
) {}
async execute({
currentStepId,
steps,
context,
runInfo,
}: WorkflowActionInput): Promise<WorkflowActionOutput> {
const step = findStepOrThrow({
steps,
@@ -69,10 +72,14 @@ export class DeleteRecordWorkflowAction implements WorkflowAction {
);
}
const executionContext =
await this.workflowExecutionContextService.getExecutionContext(runInfo);
const toolOutput = await this.deleteRecordService.execute({
objectName: workflowActionInput.objectName,
objectRecordId: workflowActionInput.objectRecordId,
workspaceId,
roleId: executionContext.roleId,
soft: true,
});
@@ -14,6 +14,7 @@ import {
WorkflowStepExecutorException,
WorkflowStepExecutorExceptionCode,
} from 'src/modules/workflow/workflow-executor/exceptions/workflow-step-executor.exception';
import { WorkflowExecutionContextService } from 'src/modules/workflow/workflow-executor/services/workflow-execution-context.service';
import { type WorkflowActionInput } from 'src/modules/workflow/workflow-executor/types/workflow-action-input';
import { type WorkflowActionOutput } from 'src/modules/workflow/workflow-executor/types/workflow-action-output.type';
import { findStepOrThrow } from 'src/modules/workflow/workflow-executor/utils/find-step-or-throw.util';
@@ -25,12 +26,14 @@ export class FindRecordsWorkflowAction implements WorkflowAction {
constructor(
private readonly findRecordsService: FindRecordsService,
private readonly scopedWorkspaceContextFactory: ScopedWorkspaceContextFactory,
private readonly workflowExecutionContextService: WorkflowExecutionContextService,
) {}
async execute({
currentStepId,
steps,
context,
runInfo,
}: WorkflowActionInput): Promise<WorkflowActionOutput> {
const step = findStepOrThrow({
steps,
@@ -58,12 +61,16 @@ export class FindRecordsWorkflowAction implements WorkflowAction {
);
}
const executionContext =
await this.workflowExecutionContextService.getExecutionContext(runInfo);
const toolOutput = await this.findRecordsService.execute({
objectName: workflowActionInput.objectName,
filter: workflowActionInput.filter?.gqlOperationFilter,
orderBy: workflowActionInput.orderBy,
limit: workflowActionInput.limit,
workspaceId,
roleId: executionContext.roleId,
});
if (!toolOutput.success) {
@@ -1,16 +1,26 @@
import { Module } from '@nestjs/common';
import { RecordCrudModule } from 'src/engine/core-modules/record-crud/record-crud.module';
import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user-workspace.module';
import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.module';
import { ScopedWorkspaceContextFactory } from 'src/engine/twenty-orm/factories/scoped-workspace-context.factory';
import { WorkflowExecutionContextService } from 'src/modules/workflow/workflow-executor/services/workflow-execution-context.service';
import { CreateRecordWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/create-record.workflow-action';
import { DeleteRecordWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/delete-record.workflow-action';
import { FindRecordsWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/find-records.workflow-action';
import { UpdateRecordWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/update-record.workflow-action';
import { WorkflowRunModule } from 'src/modules/workflow/workflow-runner/workflow-run/workflow-run.module';
@Module({
imports: [RecordCrudModule],
imports: [
RecordCrudModule,
WorkflowRunModule,
UserWorkspaceModule,
UserRoleModule,
],
providers: [
ScopedWorkspaceContextFactory,
WorkflowExecutionContextService,
CreateRecordWorkflowAction,
UpdateRecordWorkflowAction,
DeleteRecordWorkflowAction,
@@ -14,6 +14,7 @@ import {
WorkflowStepExecutorException,
WorkflowStepExecutorExceptionCode,
} from 'src/modules/workflow/workflow-executor/exceptions/workflow-step-executor.exception';
import { WorkflowExecutionContextService } from 'src/modules/workflow/workflow-executor/services/workflow-execution-context.service';
import { type WorkflowActionInput } from 'src/modules/workflow/workflow-executor/types/workflow-action-input';
import { type WorkflowActionOutput } from 'src/modules/workflow/workflow-executor/types/workflow-action-output.type';
import { findStepOrThrow } from 'src/modules/workflow/workflow-executor/utils/find-step-or-throw.util';
@@ -25,12 +26,14 @@ export class UpdateRecordWorkflowAction implements WorkflowAction {
constructor(
private readonly updateRecordService: UpdateRecordService,
private readonly scopedWorkspaceContextFactory: ScopedWorkspaceContextFactory,
private readonly workflowExecutionContextService: WorkflowExecutionContextService,
) {}
async execute({
currentStepId,
steps,
context,
runInfo,
}: WorkflowActionInput): Promise<WorkflowActionOutput> {
const step = findStepOrThrow({
steps,
@@ -69,12 +72,16 @@ export class UpdateRecordWorkflowAction implements WorkflowAction {
);
}
const executionContext =
await this.workflowExecutionContextService.getExecutionContext(runInfo);
const toolOutput = await this.updateRecordService.execute({
objectName: workflowActionInput.objectName,
objectRecordId: workflowActionInput.objectRecordId,
objectRecord: workflowActionInput.objectRecord,
fieldsToUpdate: workflowActionInput.fieldsToUpdate,
workspaceId,
roleId: executionContext.roleId,
});
if (!toolOutput.success) {