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
@@ -24,7 +24,9 @@ import { type Workspace } from 'src/engine/core-modules/workspace/workspace.enti
import { AgentHandoffToolService } from 'src/engine/metadata-modules/agent/agent-handoff-tool.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';
import { type RecordIdsByObjectMetadataNameSingularType } from 'src/engine/metadata-modules/agent/types/recordIdsByObjectMetadataNameSingular.type';
import { type ActorMetadata } from 'src/engine/metadata-modules/field-metadata/composite-types/actor.composite-type';
import { getObjectMetadataMapItemByNameSingular } from 'src/engine/metadata-modules/utils/get-object-metadata-map-item-by-name-singular.util';
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';
@@ -54,6 +56,7 @@ export class AgentExecutionService implements AgentExecutionContext {
private readonly agentToolGeneratorService: AgentToolGeneratorService,
private readonly agentModelConfigService: AgentModelConfigService,
private readonly aiBillingService: AIBillingService,
private readonly agentActorContextService: AgentActorContextService,
@InjectRepository(AgentEntity)
private readonly agentRepository: Repository<AgentEntity>,
) {}
@@ -62,11 +65,15 @@ export class AgentExecutionService implements AgentExecutionContext {
messages,
system,
agent,
actorContext,
roleIdOverride,
excludeHandoffTools = false,
}: {
system: string;
agent: AgentEntity | null;
messages: UIMessage<unknown, UIDataTypes, UITools>[];
actorContext?: ActorMetadata;
roleIdOverride?: string;
excludeHandoffTools?: boolean;
}) {
try {
@@ -87,6 +94,8 @@ export class AgentExecutionService implements AgentExecutionContext {
await this.agentToolGeneratorService.generateToolsForAgent(
agent.id,
agent.workspaceId,
actorContext,
roleIdOverride,
);
let handoffTools = {};
@@ -261,10 +270,18 @@ export class AgentExecutionService implements AgentExecutionContext {
contextString = `\n\nCONTEXT:\n${contextPart}`;
}
const { actorContext, roleId } =
await this.agentActorContextService.buildUserActorContext(
userWorkspaceId,
workspace.id,
);
const aiRequestConfig = await this.prepareAIRequestConfig({
system: `${AGENT_SYSTEM_PROMPTS.AGENT_CHAT}\n\n${agent.prompt}${contextString}`,
agent,
messages,
actorContext,
roleIdOverride: roleId,
});
this.logger.log(
@@ -7,6 +7,7 @@ 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';
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
@@ -29,6 +30,8 @@ export class AgentToolGeneratorService {
async generateToolsForAgent(
agentId: string,
workspaceId: string,
actorContext?: ActorMetadata,
roleIdOverride?: string,
): Promise<ToolSet> {
let tools: ToolSet = {};
@@ -38,15 +41,15 @@ export class AgentToolGeneratorService {
tools = { ...actionTools };
const roleId = agent.roleId;
const effectiveRoleId = roleIdOverride || agent.roleId;
if (!roleId) {
if (!effectiveRoleId) {
return tools;
}
const role = await this.roleRepository.findOne({
where: {
id: roleId,
id: effectiveRoleId,
workspaceId,
},
relations: ['permissionFlags'],
@@ -65,21 +68,22 @@ export class AgentToolGeneratorService {
if (hasWorkflowPermission) {
const workflowTools = this.workflowToolService.generateWorkflowTools(
workspaceId,
roleId,
effectiveRoleId,
);
tools = { ...tools, ...workflowTools };
}
const databaseTools = await this.toolService.listTools(
roleId,
effectiveRoleId,
workspaceId,
actorContext,
);
tools = { ...tools, ...databaseTools };
const roleActionTools = await this.toolAdapterService.getTools(
roleId,
effectiveRoleId,
workspaceId,
);
@@ -11,12 +11,14 @@ import { FileUploadModule } from 'src/engine/core-modules/file/file-upload/file-
import { FileModule } from 'src/engine/core-modules/file/file.module';
import { ThrottlerModule } from 'src/engine/core-modules/throttler/throttler.module';
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user-workspace.module';
import { AgentRoleModule } from 'src/engine/metadata-modules/agent-role/agent-role.module';
import { AgentChatController } from 'src/engine/metadata-modules/agent/agent-chat.controller';
import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
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 { WorkspacePermissionsCacheModule } from 'src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache.module';
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
import { WorkflowToolsModule } from 'src/modules/workflow/workflow-tools/workflow-tools.module';
@@ -39,6 +41,8 @@ import { AgentEntity } from './agent.entity';
import { AgentResolver } from './agent.resolver';
import { AgentService } from './agent.service';
import { AgentActorContextService } from './services/agent-actor-context.service';
@Module({
imports: [
TypeOrmModule.forFeature([
@@ -66,6 +70,8 @@ import { AgentService } from './agent.service';
TokenModule,
DomainManagerModule,
WorkflowToolsModule,
UserWorkspaceModule,
UserRoleModule,
],
controllers: [AgentChatController],
providers: [
@@ -81,6 +87,7 @@ import { AgentService } from './agent.service';
AgentTitleGenerationService,
AgentHandoffExecutorService,
AgentHandoffService,
AgentActorContextService,
],
exports: [
AgentService,
@@ -0,0 +1,71 @@
import { Injectable } from '@nestjs/common';
import { buildCreatedByFromFullNameMetadata } from 'src/engine/core-modules/actor/utils/build-created-by-from-full-name-metadata.util';
import { UserWorkspaceService as UserService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
import {
AgentException,
AgentExceptionCode,
} from 'src/engine/metadata-modules/agent/agent.exception';
import { type ActorMetadata } 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 { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
export type AgentActorContext = {
actorContext: ActorMetadata;
roleId: string | undefined;
};
@Injectable()
export class AgentActorContextService {
constructor(
private readonly userService: UserService,
private readonly userRoleService: UserRoleService,
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
) {}
async buildUserActorContext(
userWorkspaceId: string,
workspaceId: string,
): Promise<AgentActorContext> {
const userWorkspace = await this.userService.findById(userWorkspaceId);
if (!userWorkspace) {
throw new AgentException(
'User workspace not found',
AgentExceptionCode.AGENT_EXECUTION_FAILED,
);
}
const workspaceMemberRepository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace(
workspaceId,
'workspaceMember',
{ shouldBypassPermissionChecks: true },
);
const workspaceMember = await workspaceMemberRepository.findOne({
where: {
userId: userWorkspace.userId,
},
});
if (!workspaceMember) {
throw new AgentException(
'Workspace member not found for user',
AgentExceptionCode.AGENT_EXECUTION_FAILED,
);
}
const roleId = await this.userRoleService.getRoleIdForUserWorkspace({
userWorkspaceId,
workspaceId,
});
const actorContext = buildCreatedByFromFullNameMetadata({
fullNameMetadata: workspaceMember.name,
workspaceMemberId: workspaceMember.id,
});
return { actorContext, roleId };
}
}