diff --git a/packages/twenty-client-sdk/src/metadata/generated/schema.graphql b/packages/twenty-client-sdk/src/metadata/generated/schema.graphql index c8030867db..277d2c05c9 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/schema.graphql +++ b/packages/twenty-client-sdk/src/metadata/generated/schema.graphql @@ -4727,6 +4727,7 @@ input CreateEmailingDomainInput { input RunAgentInput { agentUniversalIdentifier: String! prompt: String + runAsWorkspaceMemberId: UUID messages: [RunAgentMessageInput!] } diff --git a/packages/twenty-client-sdk/src/metadata/generated/schema.ts b/packages/twenty-client-sdk/src/metadata/generated/schema.ts index 4498a40996..7d8181496f 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/schema.ts +++ b/packages/twenty-client-sdk/src/metadata/generated/schema.ts @@ -6939,7 +6939,7 @@ export interface UpdateEmailGroupChannelInput {id: Scalars['UUID'],displayName?: export interface CreateEmailingDomainInput {domain: Scalars['String']} -export interface RunAgentInput {agentUniversalIdentifier: Scalars['String'],prompt?: (Scalars['String'] | null),messages?: (RunAgentMessageInput[] | null)} +export interface RunAgentInput {agentUniversalIdentifier: Scalars['String'],prompt?: (Scalars['String'] | null),runAsWorkspaceMemberId?: (Scalars['UUID'] | null),messages?: (RunAgentMessageInput[] | null)} export interface RunAgentMessageInput {role: RunAgentMessageRole,content: Scalars['String']} diff --git a/packages/twenty-client-sdk/src/metadata/generated/types.ts b/packages/twenty-client-sdk/src/metadata/generated/types.ts index 7b26a8ede3..adcbfa268f 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/types.ts +++ b/packages/twenty-client-sdk/src/metadata/generated/types.ts @@ -12081,6 +12081,9 @@ export default { "prompt": [ 1 ], + "runAsWorkspaceMemberId": [ + 4 + ], "messages": [ 493 ], diff --git a/packages/twenty-docs/developers/extend/apps/logic/skills-and-agents.mdx b/packages/twenty-docs/developers/extend/apps/logic/skills-and-agents.mdx index 15f071fdff..6237863f2c 100644 --- a/packages/twenty-docs/developers/extend/apps/logic/skills-and-agents.mdx +++ b/packages/twenty-docs/developers/extend/apps/logic/skills-and-agents.mdx @@ -164,5 +164,44 @@ export default defineApplicationRole({ guard on whether any target field is still empty before calling `runAgent()`. +### Running on behalf of a workspace member + +Pass `runAsWorkspaceMemberId` when the run is triggered by a person — a chat +bot answering a message, for instance — so the agent acts as that member +instead of as the app: + +```ts src/logic-functions/answer-question.ts +const { result } = await runAgent({ + agentUniversalIdentifier: 'b3c4d5e6-f7a8-9012-bcde-f34567890123', + prompt: 'How many open opportunities do we have?', + runAsWorkspaceMemberId: '20202020-0687-4c41-b707-ed1bfca972a7', +}); +``` + +The run is then restricted to what **both** the agent's role and the member's +role allow, records it creates are attributed to that member, and the member's +row-level permissions apply. Omit the field for autonomous runs (scheduled +jobs, database-event triggers): those keep the agent's own role. + +Your app is responsible for mapping the person who triggered the run to a +workspace member. Naming one requires an app access token, and what a token may +name depends on whether it carries a user: + +- A token with **no user attached** may name any member. A logic function runs + with one, and so do tokens minted through `client_credentials` or from an API + key. +- A token issued **on behalf of a user**, as a front component receives, may + only name that user's own member. + +Anything else — a plain user session, an API key without an app token — may not +name a member at all. + + + `runAgent()` throws when the workspace member cannot be resolved — an + unknown or removed member, or one with no role. It never falls back to the + agent's own role, since that would grant more access than the caller asked + for. + + diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index c0dadf3e41..6a3d069019 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -5239,6 +5239,7 @@ export type RunAgentInput = { agentUniversalIdentifier: Scalars['String']['input']; messages?: InputMaybe>; prompt?: InputMaybe; + runAsWorkspaceMemberId?: InputMaybe; }; export type RunAgentMessageInput = { diff --git a/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.spec.ts index e4217f52e1..baba54800e 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.spec.ts @@ -11,6 +11,7 @@ import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-t import { ImpersonationAuthorizationService } from 'src/engine/core-modules/impersonation/services/impersonation-authorization.service'; import { NodeEnvironment } from 'src/engine/core-modules/twenty-config/interfaces/node-environment.interface'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; +import { WorkspaceActivationStatus } from 'twenty-shared/workspace'; import { JwtAuthStrategy } from './jwt.auth.strategy'; @@ -432,6 +433,182 @@ describe('JwtAuthStrategy', () => { expect(e.code).toBe(AuthExceptionCode.APPLICATION_NOT_FOUND); } }); + + it('should reject an application token bound to a user that cannot be resolved', async () => { + const validApplicationId = randomUUID(); + const validWorkspaceId = randomUUID(); + const removedUserId = randomUUID(); + + const payload = { + sub: validApplicationId, + type: JwtTokenTypeEnum.APPLICATION_ACCESS, + applicationId: validApplicationId, + workspaceId: validWorkspaceId, + userId: removedUserId, + userWorkspaceId: randomUUID(), + }; + + const mockWorkspace = new WorkspaceEntity(); + + mockWorkspace.id = validWorkspaceId; + workspaceStore[validWorkspaceId] = mockWorkspace; + applicationStore[validWorkspaceId] = { + [validApplicationId]: { id: validApplicationId }, + }; + + strategy = createStrategy(); + + try { + await strategy.validate(payload as JwtPayload); + throw new Error('Expected validate to reject'); + } catch (e) { + expect(e.code).toBe(AuthExceptionCode.USER_NOT_FOUND); + } + }); + + it('should reject an application token whose user is no longer a workspace member', async () => { + const validApplicationId = randomUUID(); + const validWorkspaceId = randomUUID(); + const validUserId = randomUUID(); + const validUserWorkspaceId = randomUUID(); + + const payload = { + sub: validApplicationId, + type: JwtTokenTypeEnum.APPLICATION_ACCESS, + applicationId: validApplicationId, + workspaceId: validWorkspaceId, + userId: validUserId, + userWorkspaceId: validUserWorkspaceId, + }; + + const mockWorkspace = new WorkspaceEntity(); + + mockWorkspace.id = validWorkspaceId; + mockWorkspace.activationStatus = WorkspaceActivationStatus.ACTIVE; + workspaceStore[validWorkspaceId] = mockWorkspace; + applicationStore[validWorkspaceId] = { + [validApplicationId]: { id: validApplicationId }, + }; + userStore[validUserId] = { id: validUserId }; + + coreEntityCacheService.get.mockImplementation( + async (keyName: string, entityId: string) => { + if (keyName === 'workspaceEntity') { + return workspaceStore[entityId] ?? null; + } + + if (keyName === 'user') { + return userStore[entityId] ?? null; + } + + if (keyName === 'userWorkspaceEntity') { + return { + id: validUserWorkspaceId, + workspaceId: validWorkspaceId, + user: { id: validUserId }, + workspace: { id: validWorkspaceId }, + }; + } + + return null; + }, + ); + + strategy = createStrategy(); + + try { + await strategy.validate(payload as JwtPayload); + throw new Error('Expected validate to reject'); + } catch (e) { + expect(e.code).toBe(AuthExceptionCode.FORBIDDEN_EXCEPTION); + } + }); + + it('should reject an application token whose workspace member is soft-deleted', async () => { + const validApplicationId = randomUUID(); + const validWorkspaceId = randomUUID(); + const validUserId = randomUUID(); + const validUserWorkspaceId = randomUUID(); + const validWorkspaceMemberId = randomUUID(); + + const payload = { + sub: validApplicationId, + type: JwtTokenTypeEnum.APPLICATION_ACCESS, + applicationId: validApplicationId, + workspaceId: validWorkspaceId, + userId: validUserId, + userWorkspaceId: validUserWorkspaceId, + }; + + const mockWorkspace = new WorkspaceEntity(); + + mockWorkspace.id = validWorkspaceId; + mockWorkspace.activationStatus = WorkspaceActivationStatus.ACTIVE; + workspaceStore[validWorkspaceId] = mockWorkspace; + applicationStore[validWorkspaceId] = { + [validApplicationId]: { id: validApplicationId }, + }; + userStore[validUserId] = { id: validUserId }; + + workspaceCacheService.getOrRecompute.mockImplementation( + async (workspaceId: string, cacheKeys: string[]) => { + const result: Record = {}; + + if (cacheKeys.includes('flatWorkspaceMemberMaps')) { + result.flatWorkspaceMemberMaps = { + byId: { + [validWorkspaceMemberId]: { + id: validWorkspaceMemberId, + userId: validUserId, + deletedAt: new Date(), + }, + }, + idByUserId: { [validUserId]: validWorkspaceMemberId }, + }; + } + + if (cacheKeys.includes('flatApplicationMaps')) { + result.flatApplicationMaps = { + byId: applicationStore[workspaceId] ?? {}, + }; + } + + return result; + }, + ); + + coreEntityCacheService.get.mockImplementation( + async (keyName: string, entityId: string) => { + if (keyName === 'workspaceEntity') { + return workspaceStore[entityId] ?? null; + } + + if (keyName === 'user') { + return userStore[entityId] ?? null; + } + + if (keyName === 'userWorkspaceEntity') { + return { + id: validUserWorkspaceId, + workspaceId: validWorkspaceId, + user: { id: validUserId }, + workspace: { id: validWorkspaceId }, + }; + } + + return null; + }, + ); + + strategy = createStrategy(); + + try { + await strategy.validate(payload as JwtPayload); + throw new Error('Expected validate to reject'); + } catch (e) { + expect(e.code).toBe(AuthExceptionCode.FORBIDDEN_EXCEPTION); + } + }); }); describe('Impersonation validation', () => { diff --git a/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.ts b/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.ts index 20cf479486..a84d5a8bf8 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.ts @@ -363,25 +363,59 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') { expectedWorkspaceId: workspace.id, }); - if (isDefined(userContext)) { - context.user = userContext.user; - context.userWorkspace = userContext.userWorkspace; - context.userWorkspaceId = userContext.userWorkspace.id; + assertIsDefinedOrThrow( + userContext, + new AuthException( + 'User or user workspace not found', + AuthExceptionCode.USER_NOT_FOUND, + { + userFriendlyMessage: msg`User does not have access to this workspace`, + }, + ), + ); - const { flatWorkspaceMemberMaps } = - await this.workspaceCacheService.getOrRecompute(workspace.id, [ - 'flatWorkspaceMemberMaps', - ]); + context.user = userContext.user; + context.userWorkspace = userContext.userWorkspace; + context.userWorkspaceId = userContext.userWorkspace.id; - const workspaceMemberId = - flatWorkspaceMemberMaps.idByUserId[userContext.user.id]; - - if (isDefined(workspaceMemberId)) { - context.workspaceMemberId = workspaceMemberId; - context.workspaceMember = - flatWorkspaceMemberMaps.byId[workspaceMemberId]; - } + if ( + workspace.activationStatus === + WorkspaceActivationStatus.PENDING_CREATION || + workspace.activationStatus === + WorkspaceActivationStatus.ONGOING_CREATION + ) { + return context; } + + const { flatWorkspaceMemberMaps } = + await this.workspaceCacheService.getOrRecompute(workspace.id, [ + 'flatWorkspaceMemberMaps', + ]); + + const workspaceMemberId = + flatWorkspaceMemberMaps.idByUserId[userContext.user.id]; + + const cachedWorkspaceMember = isDefined(workspaceMemberId) + ? flatWorkspaceMemberMaps.byId[workspaceMemberId] + : undefined; + + const workspaceMember = isDefined(cachedWorkspaceMember?.deletedAt) + ? undefined + : cachedWorkspaceMember; + + assertIsDefinedOrThrow( + workspaceMember, + new AuthException( + 'User is not a member of the workspace', + AuthExceptionCode.FORBIDDEN_EXCEPTION, + { + userFriendlyMessage: msg`User is not a member of the workspace.`, + }, + ), + ); + + context.workspaceMemberId = workspaceMemberId; + context.workspaceMember = workspaceMember; } return context; diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/services/tool-registry.service.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/services/tool-registry.service.ts index 5aca1fe678..6f034e4b84 100644 --- a/packages/twenty-server/src/engine/core-modules/tool-provider/services/tool-registry.service.ts +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/services/tool-registry.service.ts @@ -159,11 +159,13 @@ export class ToolRegistryService { userId?: string; userWorkspaceId?: string; locale?: keyof typeof APP_LOCALES; + rolePermissionConfig?: RolePermissionConfig; }, ): Promise { const context = this.buildContextFromToolContext({ workspaceId, roleId, + rolePermissionConfig: options?.rolePermissionConfig, userId: options?.userId, userWorkspaceId: options?.userWorkspaceId, locale: options?.locale, @@ -407,9 +409,10 @@ export class ToolRegistryService { private buildContextFromToolContext( context: ToolContext, ): ToolProviderContext { - const rolePermissionConfig: RolePermissionConfig = { - unionOf: [context.roleId], - }; + const rolePermissionConfig: RolePermissionConfig = + context.rolePermissionConfig ?? { + unionOf: [context.roleId], + }; return { workspaceId: context.workspaceId, diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/types/tool-context.type.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/types/tool-context.type.ts index 73664e7d64..f884ceeb2c 100644 --- a/packages/twenty-server/src/engine/core-modules/tool-provider/types/tool-context.type.ts +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/types/tool-context.type.ts @@ -4,10 +4,12 @@ import { type APP_LOCALES } from 'twenty-shared/translations'; import { type CodeExecutionStreamEmitter } from 'src/engine/core-modules/tool-provider/interfaces/code-execution-stream-emitter.type'; import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type'; +import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config'; export type ToolContext = { workspaceId: string; roleId: string; + rolePermissionConfig?: RolePermissionConfig; authContext?: WorkspaceAuthContext; actorContext?: ActorMetadata; userId?: string; diff --git a/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.ts b/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.ts index 42f8d8d84e..e23d4283f0 100644 --- a/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.ts +++ b/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.ts @@ -435,6 +435,24 @@ export class UserWorkspaceService { }; } + async getUserWorkspaceForUser({ + userId, + workspaceId, + relations = ['twoFactorAuthenticationMethods'], + }: { + userId: string; + workspaceId: string; + relations?: string[]; + }): Promise { + return this.userWorkspaceRepository.findOne({ + where: { + userId, + workspaceId, + }, + relations, + }); + } + async getUserWorkspaceForUserOrThrow({ userId, workspaceId, @@ -444,11 +462,9 @@ export class UserWorkspaceService { workspaceId: string; relations?: string[]; }): Promise { - const userWorkspace = await this.userWorkspaceRepository.findOne({ - where: { - userId, - workspaceId, - }, + const userWorkspace = await this.getUserWorkspaceForUser({ + userId, + workspaceId, relations, }); @@ -459,13 +475,13 @@ export class UserWorkspaceService { return userWorkspace; } - async getWorkspaceMemberOrThrow({ + async getWorkspaceMember({ workspaceMemberId, workspaceId, }: { workspaceMemberId: string; workspaceId: string; - }): Promise { + }): Promise { const authContext = buildSystemAuthContext(workspaceId); return this.globalWorkspaceOrmManager.executeInWorkspaceContext( @@ -477,22 +493,35 @@ export class UserWorkspaceService { { shouldBypassPermissionChecks: true }, ); - const workspaceMember = await workspaceMemberRepository.findOne({ + return workspaceMemberRepository.findOne({ where: { id: workspaceMemberId, }, }); - - if (!isDefined(workspaceMember)) { - throw new Error('Workspace member not found'); - } - - return workspaceMember; }, authContext, ); } + async getWorkspaceMemberOrThrow({ + workspaceMemberId, + workspaceId, + }: { + workspaceMemberId: string; + workspaceId: string; + }): Promise { + const workspaceMember = await this.getWorkspaceMember({ + workspaceMemberId, + workspaceId, + }); + + if (!isDefined(workspaceMember)) { + throw new Error('Workspace member not found'); + } + + return workspaceMember; + } + private async computeDefaultAvatarUrl( userId: string, workspaceId: string, diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/ai-agent-execution.module.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/ai-agent-execution.module.ts index 9b9ec2db93..f2ef55c39e 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/ai-agent-execution.module.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/ai-agent-execution.module.ts @@ -12,6 +12,7 @@ import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.ent import { AiAgentModule } from 'src/engine/metadata-modules/ai/ai-agent/ai-agent.module'; import { AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity'; import { AiBillingModule } from 'src/engine/metadata-modules/ai/ai-billing/ai-billing.module'; +import { AiGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules/ai/interceptors/ai-graphql-api-exception.interceptor'; import { AiModelsModule } from 'src/engine/metadata-modules/ai/ai-models/ai-models.module'; import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module'; import { RoleTargetEntity } from 'src/engine/metadata-modules/role-target/role-target.entity'; @@ -55,6 +56,7 @@ import { AgentRunService } from './services/agent-run.service'; providers: [ AgentAsyncExecutorService, AgentActorContextService, + AiGraphqlApiExceptionInterceptor, AgentMessagePartResolver, AgentRunResolver, AgentRunService, diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/dtos/run-agent.input.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/dtos/run-agent.input.ts index 6094b84f21..400a537d09 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/dtos/run-agent.input.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/dtos/run-agent.input.ts @@ -9,10 +9,13 @@ import { IsOptional, IsString, ValidateNested, + IsUUID, } from 'class-validator'; import { RunAgentMessageInputDTO } from 'src/engine/metadata-modules/ai/ai-agent-execution/dtos/run-agent-message.input'; +import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; + @InputType('RunAgentInput') export class RunAgentInputDTO { @IsString() @@ -26,6 +29,11 @@ export class RunAgentInputDTO { @Field({ nullable: true }) prompt?: string; + @IsUUID() + @IsOptional() + @Field(() => UUIDScalarType, { nullable: true }) + runAsWorkspaceMemberId?: string; + @IsOptional() @IsArray() @ArrayNotEmpty() diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/resolvers/agent-run.resolver.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/resolvers/agent-run.resolver.ts index 245b8aaedb..4a9059e9b6 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/resolvers/agent-run.resolver.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/resolvers/agent-run.resolver.ts @@ -1,19 +1,24 @@ -import { UseGuards } from '@nestjs/common'; +import { UseGuards, UseInterceptors } from '@nestjs/common'; import { Args, Mutation } from '@nestjs/graphql'; import { PermissionFlagType } from 'twenty-shared/constants'; import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator'; +import { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type'; import { type FlatWorkspace } from 'src/engine/core-modules/workspace/types/flat-workspace.type'; +import { AuthApplication } from 'src/engine/decorators/auth/auth-application.decorator'; import { AuthUserWorkspaceId } from 'src/engine/decorators/auth/auth-user-workspace-id.decorator'; +import { AuthWorkspaceMemberId } from 'src/engine/decorators/auth/auth-workspace-member-id.decorator'; import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator'; import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard'; import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; import { RunAgentInputDTO } from 'src/engine/metadata-modules/ai/ai-agent-execution/dtos/run-agent.input'; import { RunAgentResultDTO } from 'src/engine/metadata-modules/ai/ai-agent-execution/dtos/run-agent-result.dto'; import { AgentRunService } from 'src/engine/metadata-modules/ai/ai-agent-execution/services/agent-run.service'; +import { AiGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules/ai/interceptors/ai-graphql-api-exception.interceptor'; @UseGuards(WorkspaceAuthGuard, SettingsPermissionGuard(PermissionFlagType.AI)) +@UseInterceptors(AiGraphqlApiExceptionInterceptor) @MetadataResolver() export class AgentRunResolver { constructor(private readonly agentRunService: AgentRunService) {} @@ -24,10 +29,16 @@ export class AgentRunResolver { @AuthWorkspace() workspace: FlatWorkspace, @AuthUserWorkspaceId({ allowUndefined: true }) userWorkspaceId: string | undefined, + @AuthApplication({ allowUndefined: true }) + callerApplication: FlatApplication | undefined, + @AuthWorkspaceMemberId() + workspaceMemberId: string | undefined, ): Promise { return this.agentRunService.run({ workspace, requestUserWorkspaceId: userWorkspaceId ?? null, + requestWorkspaceMemberId: workspaceMemberId ?? null, + callerApplication, input, }); } diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/__tests__/agent-actor-context.service.spec.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/__tests__/agent-actor-context.service.spec.ts new file mode 100644 index 0000000000..76a846be96 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/__tests__/agent-actor-context.service.spec.ts @@ -0,0 +1,133 @@ +import { Test, type TestingModule } from '@nestjs/testing'; + +import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service'; +import { AgentActorContextService } from 'src/engine/metadata-modules/ai/ai-agent-execution/services/agent-actor-context.service'; +import { AiExceptionCode } from 'src/engine/metadata-modules/ai/ai.exception'; +import { + PermissionsException, + PermissionsExceptionCode, +} from 'src/engine/metadata-modules/permissions/permissions.exception'; +import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service'; +import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager'; + +describe('AgentActorContextService', () => { + let service: AgentActorContextService; + let userWorkspaceService: { + getWorkspaceMember: jest.Mock; + getUserWorkspaceForUser: jest.Mock; + }; + let userRoleService: { getRoleIdForUserWorkspace: jest.Mock }; + + const workspaceId = 'workspace-1'; + const workspaceMemberId = 'workspace-member-1'; + + const runAsArgs = { workspaceMemberId, workspaceId }; + + beforeEach(async () => { + userWorkspaceService = { + getWorkspaceMember: jest.fn().mockResolvedValue({ + id: workspaceMemberId, + userId: 'user-1', + name: { firstName: 'Ada', lastName: 'Lovelace' }, + }), + getUserWorkspaceForUser: jest.fn().mockResolvedValue({ + id: 'user-workspace-1', + userId: 'user-1', + workspace: { + id: workspaceId, + createdAt: new Date(0), + updatedAt: new Date(0), + }, + user: { + id: 'user-1', + createdAt: new Date(0), + updatedAt: new Date(0), + }, + }), + }; + userRoleService = { + getRoleIdForUserWorkspace: jest.fn().mockResolvedValue('role-1'), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AgentActorContextService, + { provide: UserWorkspaceService, useValue: userWorkspaceService }, + { provide: UserRoleService, useValue: userRoleService }, + { provide: GlobalWorkspaceOrmManager, useValue: {} }, + ], + }).compile(); + + service = module.get(AgentActorContextService); + }); + + it('reports a not-found error when the workspace member does not exist', async () => { + userWorkspaceService.getWorkspaceMember.mockResolvedValue(null); + + await expect( + service.buildRunAsWorkspaceMemberContext(runAsArgs), + ).rejects.toMatchObject({ + code: AiExceptionCode.RUN_AS_WORKSPACE_MEMBER_NOT_FOUND, + }); + }); + + it('reports a not-found error when the member has no user workspace', async () => { + userWorkspaceService.getUserWorkspaceForUser.mockResolvedValue(null); + + await expect( + service.buildRunAsWorkspaceMemberContext(runAsArgs), + ).rejects.toMatchObject({ + code: AiExceptionCode.RUN_AS_WORKSPACE_MEMBER_NOT_FOUND, + }); + }); + + it('reports a not-found error when the member has no role assigned', async () => { + userRoleService.getRoleIdForUserWorkspace.mockRejectedValue( + new PermissionsException( + 'No role found for userWorkspace', + PermissionsExceptionCode.NO_ROLE_FOUND_FOR_USER_WORKSPACE, + ), + ); + + await expect( + service.buildRunAsWorkspaceMemberContext(runAsArgs), + ).rejects.toMatchObject({ + code: AiExceptionCode.RUN_AS_WORKSPACE_MEMBER_NOT_FOUND, + }); + }); + + it('lets an infrastructure failure keep its own error type', async () => { + const databaseError = new Error('connection terminated unexpectedly'); + + userWorkspaceService.getWorkspaceMember.mockRejectedValue(databaseError); + + await expect( + service.buildRunAsWorkspaceMemberContext(runAsArgs), + ).rejects.toBe(databaseError); + }); + + it('lets an unrelated permissions failure keep its own error type', async () => { + const permissionsError = new PermissionsException( + 'Permission denied', + PermissionsExceptionCode.PERMISSION_DENIED, + ); + + userRoleService.getRoleIdForUserWorkspace.mockRejectedValue( + permissionsError, + ); + + await expect( + service.buildRunAsWorkspaceMemberContext(runAsArgs), + ).rejects.toBe(permissionsError); + }); + + it('returns the member actor context, auth context and role', async () => { + const result = await service.buildRunAsWorkspaceMemberContext(runAsArgs); + + expect(result.roleId).toBe('role-1'); + expect(result.authContext).toMatchObject({ + type: 'user', + workspaceMemberId, + }); + }); +}); diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/__tests__/agent-async-executor.service.spec.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/__tests__/agent-async-executor.service.spec.ts index 1937dee05a..e62fbb92ec 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/__tests__/agent-async-executor.service.spec.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/__tests__/agent-async-executor.service.spec.ts @@ -211,6 +211,49 @@ describe('AgentAsyncExecutorService — workflow agent role-scoped tool resoluti expect(system).not.toContain('create_one_workflow'); }); + it('intersects the agent role with the run-as role on the lazy path used by runAgent', async () => { + roleTargetRepository.findOne.mockResolvedValueOnce({ roleId: agentRoleId }); + toolRegistry.buildToolIndex.mockResolvedValueOnce([]); + + await service.executeAgent({ + agent: buildAgent(), + messages: [{ role: 'user', content: 'test' }], + baseSystemPrompt: 'base system prompt', + workspaceId, + runAsRoleId: 'run-as-role-id', + toolLoadingStrategy: 'lazy', + }); + + expect(toolRegistry.buildToolIndex).toHaveBeenCalledWith( + workspaceId, + agentRoleId, + expect.objectContaining({ + rolePermissionConfig: { + intersectionOf: [agentRoleId, 'run-as-role-id'], + }, + }), + ); + }); + + it('leaves the lazy path on its default role resolution without a run-as role', async () => { + roleTargetRepository.findOne.mockResolvedValueOnce({ roleId: agentRoleId }); + toolRegistry.buildToolIndex.mockResolvedValueOnce([]); + + await service.executeAgent({ + agent: buildAgent(), + messages: [{ role: 'user', content: 'test' }], + baseSystemPrompt: 'base system prompt', + workspaceId, + toolLoadingStrategy: 'lazy', + }); + + expect(toolRegistry.buildToolIndex).toHaveBeenCalledWith( + workspaceId, + agentRoleId, + expect.objectContaining({ rolePermissionConfig: undefined }), + ); + }); + it('does not resolve registry tools when the agent has no role (fail-closed)', async () => { roleTargetRepository.findOne.mockResolvedValueOnce(null); diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/__tests__/agent-run.service.spec.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/__tests__/agent-run.service.spec.ts index a3a5d7fc3b..f5478c82f3 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/__tests__/agent-run.service.spec.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/__tests__/agent-run.service.spec.ts @@ -2,10 +2,16 @@ import { Test, type TestingModule } from '@nestjs/testing'; import { type FlatWorkspace } from 'src/engine/core-modules/workspace/types/flat-workspace.type'; import { ApplicationService } from 'src/engine/core-modules/application/application.service'; +import { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type'; +import { AgentActorContextService } from 'src/engine/metadata-modules/ai/ai-agent-execution/services/agent-actor-context.service'; import { AgentAsyncExecutorService } from 'src/engine/metadata-modules/ai/ai-agent-execution/services/agent-async-executor.service'; import { AgentRunService } from 'src/engine/metadata-modules/ai/ai-agent-execution/services/agent-run.service'; import { AGENT_RUN_BASE_SYSTEM_PROMPT } from 'src/engine/metadata-modules/ai/ai-agent/constants/agent-run-base-system-prompt.const'; import { AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity'; +import { + AiException, + AiExceptionCode, +} from 'src/engine/metadata-modules/ai/ai.exception'; import { getWorkspaceScopedRepositoryToken } from 'src/engine/twenty-orm/workspace-scoped-repository/get-workspace-scoped-repository-token.util'; describe('AgentRunService', () => { @@ -13,6 +19,9 @@ describe('AgentRunService', () => { let agentRepository: { findOne: jest.Mock }; let applicationService: { findById: jest.Mock }; let agentAsyncExecutorService: { executeAgent: jest.Mock }; + let agentActorContextService: { + buildRunAsWorkspaceMemberContext: jest.Mock; + }; const workspace = { id: 'workspace-1' } as FlatWorkspace; @@ -23,6 +32,22 @@ describe('AgentRunService', () => { prompt: 'Enrich record 123', }; + const callerApplication = { id: 'app-1' } as FlatApplication; + + const runAsActorContext = { + source: 'AGENT', + workspaceMemberId: 'workspace-member-1', + }; + + const runAsAuthContext = { + type: 'user', + workspace, + userWorkspaceId: 'member-user-workspace-1', + user: { id: 'user-1' }, + workspaceMemberId: 'workspace-member-1', + workspaceMember: { id: 'workspace-member-1' }, + }; + beforeEach(async () => { agentRepository = { findOne: jest.fn().mockResolvedValue(agent) }; applicationService = { @@ -34,10 +59,21 @@ describe('AgentRunService', () => { hasNoMoreAvailableCredits: false, }), }; + agentActorContextService = { + buildRunAsWorkspaceMemberContext: jest.fn().mockResolvedValue({ + actorContext: runAsActorContext, + authContext: runAsAuthContext, + roleId: 'member-role-1', + }), + }; const module: TestingModule = await Test.createTestingModule({ providers: [ AgentRunService, + { + provide: AgentActorContextService, + useValue: agentActorContextService, + }, { provide: AgentAsyncExecutorService, useValue: agentAsyncExecutorService, @@ -60,6 +96,7 @@ describe('AgentRunService', () => { const result = await service.run({ workspace, requestUserWorkspaceId: 'user-workspace-1', + requestWorkspaceMemberId: null, input, }); @@ -82,6 +119,7 @@ describe('AgentRunService', () => { await service.run({ workspace, requestUserWorkspaceId: 'user-workspace-1', + requestWorkspaceMemberId: null, input, }); @@ -97,10 +135,30 @@ describe('AgentRunService', () => { ); }); + it('does not resolve a run-as context when no workspace member is requested', async () => { + await service.run({ + workspace, + requestUserWorkspaceId: 'user-workspace-1', + requestWorkspaceMemberId: null, + input, + }); + + expect( + agentActorContextService.buildRunAsWorkspaceMemberContext, + ).not.toHaveBeenCalled(); + expect(agentAsyncExecutorService.executeAgent).toHaveBeenCalledWith( + expect.objectContaining({ + actorContext: undefined, + runAsRoleId: undefined, + }), + ); + }); + it('converts a prompt into a single user message', async () => { await service.run({ workspace, requestUserWorkspaceId: null, + requestWorkspaceMemberId: null, input, }); @@ -109,6 +167,54 @@ describe('AgentRunService', () => { ).toEqual([{ role: 'user', content: input.prompt }]); }); + it('runs as the requested workspace member with their auth context and role', async () => { + await service.run({ + workspace, + requestUserWorkspaceId: null, + requestWorkspaceMemberId: null, + callerApplication, + input: { ...input, runAsWorkspaceMemberId: 'workspace-member-1' }, + }); + + expect( + agentActorContextService.buildRunAsWorkspaceMemberContext, + ).toHaveBeenCalledWith({ + workspaceMemberId: 'workspace-member-1', + workspaceId: workspace.id, + }); + expect(agentAsyncExecutorService.executeAgent).toHaveBeenCalledWith( + expect.objectContaining({ + actorContext: runAsActorContext, + authContext: runAsAuthContext, + runAsRoleId: 'member-role-1', + userWorkspaceId: 'member-user-workspace-1', + }), + ); + }); + + it('fails the run instead of falling back to the agent role when the member cannot be resolved', async () => { + agentActorContextService.buildRunAsWorkspaceMemberContext.mockRejectedValue( + new AiException( + 'Workspace member not found', + AiExceptionCode.RUN_AS_WORKSPACE_MEMBER_NOT_FOUND, + ), + ); + + await expect( + service.run({ + workspace, + requestUserWorkspaceId: null, + requestWorkspaceMemberId: null, + callerApplication, + input: { ...input, runAsWorkspaceMemberId: 'workspace-member-1' }, + }), + ).rejects.toMatchObject({ + code: AiExceptionCode.RUN_AS_WORKSPACE_MEMBER_NOT_FOUND, + }); + + expect(agentAsyncExecutorService.executeAgent).not.toHaveBeenCalled(); + }); + it('passes messages to the executor when messages are provided instead of prompt', async () => { const messages = [ { role: 'user' as const, content: 'Hello' }, @@ -119,6 +225,7 @@ describe('AgentRunService', () => { await service.run({ workspace, requestUserWorkspaceId: 'user-workspace-1', + requestWorkspaceMemberId: null, input: { agentUniversalIdentifier: 'agent-uid', messages, @@ -145,6 +252,7 @@ describe('AgentRunService', () => { service.run({ workspace, requestUserWorkspaceId: null, + requestWorkspaceMemberId: null, input: { agentUniversalIdentifier: 'agent-uid', }, @@ -155,11 +263,124 @@ describe('AgentRunService', () => { expect(agentAsyncExecutorService.executeAgent).not.toHaveBeenCalled(); }); + it('rejects running as a workspace member when the caller is not an application', async () => { + await expect( + service.run({ + workspace, + requestUserWorkspaceId: 'user-workspace-1', + requestWorkspaceMemberId: null, + input: { ...input, runAsWorkspaceMemberId: 'workspace-member-1' }, + }), + ).rejects.toThrow('requires an application access token'); + + expect( + agentActorContextService.buildRunAsWorkspaceMemberContext, + ).not.toHaveBeenCalled(); + expect(agentAsyncExecutorService.executeAgent).not.toHaveBeenCalled(); + }); + + it('rejects an application token issued for a user naming a different member', async () => { + await expect( + service.run({ + workspace, + requestUserWorkspaceId: 'user-workspace-1', + requestWorkspaceMemberId: 'workspace-member-2', + callerApplication, + input: { ...input, runAsWorkspaceMemberId: 'workspace-member-1' }, + }), + ).rejects.toThrow('can only run an agent as that user'); + + expect( + agentActorContextService.buildRunAsWorkspaceMemberContext, + ).not.toHaveBeenCalled(); + expect(agentAsyncExecutorService.executeAgent).not.toHaveBeenCalled(); + }); + + it('rejects a user-bound application token whose member identity never resolved', async () => { + await expect( + service.run({ + workspace, + requestUserWorkspaceId: 'user-workspace-1', + requestWorkspaceMemberId: null, + callerApplication, + input: { ...input, runAsWorkspaceMemberId: 'workspace-member-1' }, + }), + ).rejects.toThrow('can only run an agent as that user'); + + expect( + agentActorContextService.buildRunAsWorkspaceMemberContext, + ).not.toHaveBeenCalled(); + expect(agentAsyncExecutorService.executeAgent).not.toHaveBeenCalled(); + }); + + it('allows an application token with no user binding to name any member', async () => { + await service.run({ + workspace, + requestUserWorkspaceId: null, + requestWorkspaceMemberId: null, + callerApplication, + input: { ...input, runAsWorkspaceMemberId: 'someone-elses-member-id' }, + }); + + expect( + agentActorContextService.buildRunAsWorkspaceMemberContext, + ).toHaveBeenCalledWith({ + workspaceMemberId: 'someone-elses-member-id', + workspaceId: workspace.id, + }); + }); + + it('rejects running an agent that belongs to another application', async () => { + await expect( + service.run({ + workspace, + requestUserWorkspaceId: null, + requestWorkspaceMemberId: null, + callerApplication: { id: 'another-app' } as FlatApplication, + input, + }), + ).rejects.toMatchObject({ + code: AiExceptionCode.RUN_AGENT_NOT_ALLOWED, + }); + + expect(agentAsyncExecutorService.executeAgent).not.toHaveBeenCalled(); + }); + + it('allows an application to run its own agent', async () => { + await service.run({ + workspace, + requestUserWorkspaceId: null, + requestWorkspaceMemberId: null, + callerApplication, + input, + }); + + expect(agentAsyncExecutorService.executeAgent).toHaveBeenCalled(); + }); + + it('allows an application token issued for a user to run as that same user', async () => { + await service.run({ + workspace, + requestUserWorkspaceId: 'user-workspace-1', + requestWorkspaceMemberId: 'workspace-member-1', + callerApplication, + input: { ...input, runAsWorkspaceMemberId: 'workspace-member-1' }, + }); + + expect( + agentActorContextService.buildRunAsWorkspaceMemberContext, + ).toHaveBeenCalledWith({ + workspaceMemberId: 'workspace-member-1', + workspaceId: workspace.id, + }); + }); + it('throws when both prompt and messages are provided', async () => { await expect( service.run({ workspace, requestUserWorkspaceId: null, + requestWorkspaceMemberId: null, input: { agentUniversalIdentifier: 'agent-uid', prompt: 'Enrich record 123', @@ -176,6 +397,7 @@ describe('AgentRunService', () => { await service.run({ workspace, requestUserWorkspaceId: null, + requestWorkspaceMemberId: null, input, }); @@ -196,6 +418,7 @@ describe('AgentRunService', () => { const result = await service.run({ workspace, requestUserWorkspaceId: 'user-workspace-1', + requestWorkspaceMemberId: null, input, }); @@ -214,6 +437,7 @@ describe('AgentRunService', () => { const result = await service.run({ workspace, requestUserWorkspaceId: 'user-workspace-1', + requestWorkspaceMemberId: null, input, }); @@ -231,6 +455,7 @@ describe('AgentRunService', () => { service.run({ workspace, requestUserWorkspaceId: null, + requestWorkspaceMemberId: null, input, }), ).rejects.toThrow(/not found/); @@ -246,6 +471,7 @@ describe('AgentRunService', () => { service.run({ workspace, requestUserWorkspaceId: null, + requestWorkspaceMemberId: null, input, }), ).rejects.toThrow(/not found/); diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/agent-actor-context.service.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/agent-actor-context.service.ts index ae26471471..c021a2721c 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/agent-actor-context.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/agent-actor-context.service.ts @@ -1,13 +1,22 @@ import { Injectable } from '@nestjs/common'; import { type ActorMetadata, FieldActorSource } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; import { buildCreatedByFromFullNameMetadata } from 'src/engine/core-modules/actor/utils/build-created-by-from-full-name-metadata.util'; +import { buildUserAuthContext } from 'src/engine/core-modules/auth/utils/build-user-auth-context.util'; +import { fromUserEntityToFlat } from 'src/engine/core-modules/user/utils/from-user-entity-to-flat.util'; import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service'; +import { fromWorkspaceEntityToFlat } from 'src/engine/core-modules/workspace/utils/from-workspace-entity-to-flat.util'; import { AiException, AiExceptionCode, } from 'src/engine/metadata-modules/ai/ai.exception'; +import { type RunAsWorkspaceMemberContext } from 'src/engine/metadata-modules/ai/ai-agent-execution/types/run-as-workspace-member-context.type'; +import { + PermissionsException, + PermissionsExceptionCode, +} from 'src/engine/metadata-modules/permissions/permissions.exception'; import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service'; import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager'; import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util'; @@ -113,4 +122,89 @@ export class AgentActorContextService { userContext, }; } + + async buildRunAsWorkspaceMemberContext({ + workspaceMemberId, + workspaceId, + }: { + workspaceMemberId: string; + workspaceId: string; + }): Promise { + const workspaceMember = await this.userWorkspaceService.getWorkspaceMember({ + workspaceMemberId, + workspaceId, + }); + + if (!isDefined(workspaceMember)) { + throw new AiException( + `Workspace member ${workspaceMemberId} not found`, + AiExceptionCode.RUN_AS_WORKSPACE_MEMBER_NOT_FOUND, + ); + } + + const userWorkspace = + await this.userWorkspaceService.getUserWorkspaceForUser({ + userId: workspaceMember.userId, + workspaceId, + relations: ['workspace', 'user'], + }); + + if (!isDefined(userWorkspace)) { + throw new AiException( + `Workspace member ${workspaceMemberId} has no user workspace`, + AiExceptionCode.RUN_AS_WORKSPACE_MEMBER_NOT_FOUND, + ); + } + + const roleId = await this.resolveRoleIdOrThrow({ + userWorkspaceId: userWorkspace.id, + workspaceId, + workspaceMemberId, + }); + + return { + actorContext: buildCreatedByFromFullNameMetadata({ + fullNameMetadata: workspaceMember.name, + workspaceMemberId: workspaceMember.id, + source: FieldActorSource.AGENT, + }), + authContext: buildUserAuthContext({ + workspace: fromWorkspaceEntityToFlat(userWorkspace.workspace), + userWorkspaceId: userWorkspace.id, + user: fromUserEntityToFlat(userWorkspace.user), + workspaceMemberId: workspaceMember.id, + workspaceMember, + }), + roleId, + }; + } + + private async resolveRoleIdOrThrow({ + userWorkspaceId, + workspaceId, + workspaceMemberId, + }: { + userWorkspaceId: string; + workspaceId: string; + workspaceMemberId: string; + }): Promise { + try { + return await this.userRoleService.getRoleIdForUserWorkspace({ + userWorkspaceId, + workspaceId, + }); + } catch (error) { + if ( + error instanceof PermissionsException && + error.code === PermissionsExceptionCode.NO_ROLE_FOUND_FOR_USER_WORKSPACE + ) { + throw new AiException( + `Workspace member ${workspaceMemberId} has no role assigned`, + AiExceptionCode.RUN_AS_WORKSPACE_MEMBER_NOT_FOUND, + ); + } + + throw error; + } + } } diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/agent-async-executor.service.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/agent-async-executor.service.ts index 7c51128857..fdfa2a65c6 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/agent-async-executor.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/agent-async-executor.service.ts @@ -47,6 +47,7 @@ import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.ent import { WORKFLOW_AGENT_REGISTRY_TOOL_CATEGORIES } from 'src/engine/metadata-modules/ai/ai-agent-execution/constants/workflow-agent-registry-tool-categories.const'; import { type AgentExecutionResult } from 'src/engine/metadata-modules/ai/ai-agent-execution/types/agent-execution-result.type'; import { type AgentToolLoadingStrategy } from 'src/engine/metadata-modules/ai/ai-agent-execution/types/agent-tool-loading-strategy.type'; +import { buildAgentRolePermissionConfig } from 'src/engine/metadata-modules/ai/ai-agent-execution/utils/build-agent-role-permission-config.util'; import { AGENT_CONFIG } from 'src/engine/metadata-modules/ai/ai-agent/constants/agent-config.const'; import { STRUCTURED_OUTPUT_SYSTEM_PROMPT } from 'src/engine/metadata-modules/ai/ai-agent/constants/structured-output-system-prompt.const'; import { type AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity'; @@ -144,11 +145,13 @@ export class AgentAsyncExecutorService { private async buildPreloadedRegistryTools({ agent, agentRoleId, + runAsRoleId, authContext, actorContext, }: { agent: AgentEntity; agentRoleId: string; + runAsRoleId?: string; authContext?: WorkspaceAuthContext; actorContext?: ActorMetadata; }): Promise { @@ -157,7 +160,10 @@ export class AgentAsyncExecutorService { const toolProviderContext: ToolProviderContext = { workspaceId: agent.workspaceId, roleId: agentRoleId, - rolePermissionConfig: { intersectionOf: [agentRoleId] }, + rolePermissionConfig: buildAgentRolePermissionConfig({ + agentRoleId, + runAsRoleId, + }), requireExplicitObjectGrants: true, authContext, actorContext, @@ -179,19 +185,26 @@ export class AgentAsyncExecutorService { private async buildLazyRegistryTools({ agent, agentRoleId, + runAsRoleId, authContext, actorContext, }: { agent: AgentEntity; agentRoleId: string; + runAsRoleId?: string; authContext?: WorkspaceAuthContext; actorContext?: ActorMetadata; }): Promise<{ tools: ToolSet; catalogSection: string }> { const { userId, userWorkspaceId } = this.resolveUserIdentity(authContext); + const rolePermissionConfig = isDefined(runAsRoleId) + ? buildAgentRolePermissionConfig({ agentRoleId, runAsRoleId }) + : undefined; + const toolContext: ToolContext = { workspaceId: agent.workspaceId, roleId: agentRoleId, + rolePermissionConfig, authContext, actorContext, userId, @@ -201,7 +214,7 @@ export class AgentAsyncExecutorService { const fullCatalog = await this.toolRegistry.buildToolIndex( agent.workspaceId, agentRoleId, - { userId, userWorkspaceId }, + { userId, userWorkspaceId, rolePermissionConfig }, ); const allowedCategories = new Set(WORKFLOW_AGENT_REGISTRY_TOOL_CATEGORIES); @@ -244,6 +257,7 @@ export class AgentAsyncExecutorService { authContext, workspaceId, userWorkspaceId, + runAsRoleId, operationType = UsageOperationType.AI_WORKFLOW_TOKEN, toolLoadingStrategy = 'preload', }: { @@ -254,6 +268,7 @@ export class AgentAsyncExecutorService { authContext?: WorkspaceAuthContext; workspaceId: string; userWorkspaceId?: string | null; + runAsRoleId?: string; operationType?: UsageOperationType; toolLoadingStrategy?: AgentToolLoadingStrategy; }): Promise { @@ -317,6 +332,7 @@ export class AgentAsyncExecutorService { const lazyToolset = await this.buildLazyRegistryTools({ agent, agentRoleId, + runAsRoleId, authContext, actorContext, }); @@ -327,6 +343,7 @@ export class AgentAsyncExecutorService { registryTools = await this.buildPreloadedRegistryTools({ agent, agentRoleId, + runAsRoleId, authContext, actorContext, }); diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/agent-run.service.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/agent-run.service.ts index 2f4c2b51b0..3c9a3fd597 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/agent-run.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/agent-run.service.ts @@ -5,13 +5,16 @@ import { type RunAgentMessage, type RunAgentResult, } from 'twenty-shared/application'; -import { isNonEmptyArray } from 'twenty-shared/utils'; +import { isDefined, isNonEmptyArray } from 'twenty-shared/utils'; import { ApplicationService } from 'src/engine/core-modules/application/application.service'; +import { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type'; import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type'; import { UsageOperationType } from 'src/engine/core-modules/usage/enums/usage-operation-type.enum'; import { type FlatWorkspace } from 'src/engine/core-modules/workspace/types/flat-workspace.type'; +import { AgentActorContextService } from 'src/engine/metadata-modules/ai/ai-agent-execution/services/agent-actor-context.service'; import { AgentAsyncExecutorService } from 'src/engine/metadata-modules/ai/ai-agent-execution/services/agent-async-executor.service'; +import { type RunAsWorkspaceMemberContext } from 'src/engine/metadata-modules/ai/ai-agent-execution/types/run-as-workspace-member-context.type'; import { AGENT_RUN_BASE_SYSTEM_PROMPT } from 'src/engine/metadata-modules/ai/ai-agent/constants/agent-run-base-system-prompt.const'; import { AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity'; import { @@ -25,6 +28,7 @@ type RunAgentServiceInput = { agentUniversalIdentifier: string; prompt?: string | null; messages?: RunAgentMessage[] | null; + runAsWorkspaceMemberId?: string; }; @Injectable() @@ -32,6 +36,7 @@ export class AgentRunService { private readonly logger = new Logger(AgentRunService.name); constructor( + private readonly agentActorContextService: AgentActorContextService, private readonly agentAsyncExecutorService: AgentAsyncExecutorService, private readonly applicationService: ApplicationService, @InjectWorkspaceScopedRepository(AgentEntity) @@ -41,10 +46,14 @@ export class AgentRunService { async run({ workspace, requestUserWorkspaceId, + requestWorkspaceMemberId, + callerApplication, input, }: { workspace: FlatWorkspace; requestUserWorkspaceId: string | null; + requestWorkspaceMemberId: string | null; + callerApplication?: FlatApplication; input: RunAgentServiceInput; }): Promise { const prompt = input.prompt; @@ -73,6 +82,16 @@ export class AgentRunService { ); } + if ( + isDefined(callerApplication) && + agent.applicationId !== callerApplication.id + ) { + throw new AiException( + `Agent ${input.agentUniversalIdentifier} belongs to another application`, + AiExceptionCode.RUN_AGENT_NOT_ALLOWED, + ); + } + const application = await this.applicationService.findById( agent.applicationId, ); @@ -83,7 +102,15 @@ export class AgentRunService { ); } - const authContext: WorkspaceAuthContext = { + const runAsContext = await this.resolveRunAsContext({ + runAsWorkspaceMemberId: input.runAsWorkspaceMemberId, + callerApplication, + requestUserWorkspaceId, + requestWorkspaceMemberId, + workspaceId: workspace.id, + }); + + const authContext: WorkspaceAuthContext = runAsContext?.authContext ?? { type: 'application', workspace, application, @@ -95,9 +122,12 @@ export class AgentRunService { agent, messages, baseSystemPrompt: AGENT_RUN_BASE_SYSTEM_PROMPT, + actorContext: runAsContext?.actorContext, authContext, workspaceId: workspace.id, - userWorkspaceId: requestUserWorkspaceId, + userWorkspaceId: + runAsContext?.authContext.userWorkspaceId ?? requestUserWorkspaceId, + runAsRoleId: runAsContext?.roleId, operationType: UsageOperationType.AI_WORKFLOW_TOKEN, toolLoadingStrategy: 'lazy', }, @@ -129,4 +159,44 @@ export class AgentRunService { }; } } + + private async resolveRunAsContext({ + runAsWorkspaceMemberId, + callerApplication, + requestUserWorkspaceId, + requestWorkspaceMemberId, + workspaceId, + }: { + runAsWorkspaceMemberId?: string; + callerApplication?: FlatApplication; + requestUserWorkspaceId: string | null; + requestWorkspaceMemberId: string | null; + workspaceId: string; + }): Promise { + if (!isDefined(runAsWorkspaceMemberId)) { + return undefined; + } + + if (!isDefined(callerApplication)) { + throw new AiException( + 'Running an agent as a workspace member requires an application access token', + AiExceptionCode.RUN_AS_WORKSPACE_MEMBER_NOT_ALLOWED, + ); + } + + if ( + isDefined(requestUserWorkspaceId) && + requestWorkspaceMemberId !== runAsWorkspaceMemberId + ) { + throw new AiException( + 'An application token issued for a user can only run an agent as that user', + AiExceptionCode.RUN_AS_WORKSPACE_MEMBER_NOT_ALLOWED, + ); + } + + return this.agentActorContextService.buildRunAsWorkspaceMemberContext({ + workspaceMemberId: runAsWorkspaceMemberId, + workspaceId, + }); + } } diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/types/run-as-workspace-member-context.type.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/types/run-as-workspace-member-context.type.ts new file mode 100644 index 0000000000..1a5cf6eb3f --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/types/run-as-workspace-member-context.type.ts @@ -0,0 +1,9 @@ +import { type ActorMetadata } from 'twenty-shared/types'; + +import { type UserWorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type'; + +export type RunAsWorkspaceMemberContext = { + actorContext: ActorMetadata; + authContext: UserWorkspaceAuthContext; + roleId: string; +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/utils/__tests__/build-agent-role-permission-config.util.spec.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/utils/__tests__/build-agent-role-permission-config.util.spec.ts new file mode 100644 index 0000000000..c313e843cb --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/utils/__tests__/build-agent-role-permission-config.util.spec.ts @@ -0,0 +1,27 @@ +import { buildAgentRolePermissionConfig } from 'src/engine/metadata-modules/ai/ai-agent-execution/utils/build-agent-role-permission-config.util'; + +describe('buildAgentRolePermissionConfig', () => { + it('keeps the agent role alone when there is no run-as role', () => { + expect( + buildAgentRolePermissionConfig({ agentRoleId: 'agent-role-id' }), + ).toEqual({ intersectionOf: ['agent-role-id'] }); + }); + + it('intersects with the run-as role, agent role first', () => { + expect( + buildAgentRolePermissionConfig({ + agentRoleId: 'agent-role-id', + runAsRoleId: 'run-as-role-id', + }), + ).toEqual({ intersectionOf: ['agent-role-id', 'run-as-role-id'] }); + }); + + it('does not repeat the role when the member already has the agent role', () => { + expect( + buildAgentRolePermissionConfig({ + agentRoleId: 'agent-role-id', + runAsRoleId: 'agent-role-id', + }), + ).toEqual({ intersectionOf: ['agent-role-id'] }); + }); +}); diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/utils/build-agent-role-permission-config.util.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/utils/build-agent-role-permission-config.util.ts new file mode 100644 index 0000000000..59396fc255 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/utils/build-agent-role-permission-config.util.ts @@ -0,0 +1,17 @@ +import { isDefined } from 'twenty-shared/utils'; + +import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config'; + +export const buildAgentRolePermissionConfig = ({ + agentRoleId, + runAsRoleId, +}: { + agentRoleId: string; + runAsRoleId?: string; +}): RolePermissionConfig => { + if (!isDefined(runAsRoleId) || runAsRoleId === agentRoleId) { + return { intersectionOf: [agentRoleId] }; + } + + return { intersectionOf: [agentRoleId, runAsRoleId] }; +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai.exception.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai.exception.ts index 1fa1ccb9dc..b89b1c0d12 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai.exception.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai.exception.ts @@ -21,6 +21,9 @@ export enum AiExceptionCode { USER_WORKSPACE_ID_NOT_FOUND = 'USER_WORKSPACE_ID_NOT_FOUND', ROLE_NOT_FOUND = 'ROLE_NOT_FOUND', ROLE_CANNOT_BE_ASSIGNED_TO_AGENTS = 'ROLE_CANNOT_BE_ASSIGNED_TO_AGENTS', + RUN_AS_WORKSPACE_MEMBER_NOT_ALLOWED = 'RUN_AS_WORKSPACE_MEMBER_NOT_ALLOWED', + RUN_AS_WORKSPACE_MEMBER_NOT_FOUND = 'RUN_AS_WORKSPACE_MEMBER_NOT_FOUND', + RUN_AGENT_NOT_ALLOWED = 'RUN_AGENT_NOT_ALLOWED', NO_FAILED_TURN_TO_RETRY = 'NO_FAILED_TURN_TO_RETRY', STREAM_INTERRUPTED = 'STREAM_INTERRUPTED', } @@ -59,6 +62,12 @@ const getAiExceptionUserFriendlyMessage = (code: AiExceptionCode) => { return msg`Role not found.`; case AiExceptionCode.ROLE_CANNOT_BE_ASSIGNED_TO_AGENTS: return msg`This role cannot be assigned to agents.`; + case AiExceptionCode.RUN_AS_WORKSPACE_MEMBER_NOT_ALLOWED: + return msg`This action is not available for your request.`; + case AiExceptionCode.RUN_AS_WORKSPACE_MEMBER_NOT_FOUND: + return msg`Workspace member not found.`; + case AiExceptionCode.RUN_AGENT_NOT_ALLOWED: + return msg`This action is not available for your request.`; case AiExceptionCode.NO_FAILED_TURN_TO_RETRY: return msg`There is no failed message to retry.`; case AiExceptionCode.STREAM_INTERRUPTED: diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/utils/ai-graphql-api-exception-handler.util.ts b/packages/twenty-server/src/engine/metadata-modules/ai/utils/ai-graphql-api-exception-handler.util.ts index 30411ef6e0..be5eea6beb 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/utils/ai-graphql-api-exception-handler.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/utils/ai-graphql-api-exception-handler.util.ts @@ -26,6 +26,7 @@ export const aiGraphqlApiExceptionHandler = (error: Error) => { case AiExceptionCode.WORKSPACE_NOT_FOUND: case AiExceptionCode.MESSAGE_NOT_FOUND: case AiExceptionCode.ROLE_NOT_FOUND: + case AiExceptionCode.RUN_AS_WORKSPACE_MEMBER_NOT_FOUND: throw new NotFoundError(error); case AiExceptionCode.CONTEXT_WINDOW_EXCEEDED: case AiExceptionCode.INVALID_AGENT_INPUT: @@ -38,6 +39,8 @@ export const aiGraphqlApiExceptionHandler = (error: Error) => { throw new ConflictError(error); case AiExceptionCode.AGENT_IS_STANDARD: case AiExceptionCode.ROLE_CANNOT_BE_ASSIGNED_TO_AGENTS: + case AiExceptionCode.RUN_AS_WORKSPACE_MEMBER_NOT_ALLOWED: + case AiExceptionCode.RUN_AGENT_NOT_ALLOWED: throw new ForbiddenError(error); case AiExceptionCode.AGENT_EXECUTION_FAILED: case AiExceptionCode.API_KEY_NOT_CONFIGURED: diff --git a/packages/twenty-shared/src/application/runAgentType.ts b/packages/twenty-shared/src/application/runAgentType.ts index 3f93cf542e..a811710100 100644 --- a/packages/twenty-shared/src/application/runAgentType.ts +++ b/packages/twenty-shared/src/application/runAgentType.ts @@ -7,6 +7,7 @@ export type RunAgentMessage = { export type RunAgentInput = { agentUniversalIdentifier: string; + runAsWorkspaceMemberId?: string; } & ( | { prompt: string; messages?: never } | { messages: RunAgentMessage[]; prompt?: never }