feat: Add agent role assignment and database CRUD tools for AI agent nodes (#12888)

This PR introduces a significant enhancement to the role-based
permission system by extending it to support AI agents, enabling them to
perform database operations based on assigned permissions.

## Key Changes

### 1. Database Schema Migration
- **Table Rename**: `userWorkspaceRole` → `roleTargets` to better
reflect its expanded purpose
- **New Column**: Added `agentId` (UUID, nullable) to support AI agent
role assignments
- **Constraint Updates**: 
- Made `userWorkspaceId` nullable to accommodate agent-only role
assignments
- Added check constraint `CHK_role_targets_either_agent_or_user`
ensuring either `agentId` OR `userWorkspaceId` is set (not both)

### 2. Entity & Service Layer Updates
- **RoleTargetsEntity**: Updated with new `agentId` field and constraint
validation
- **AgentRoleService**: New service for managing agent role assignments
with validation
- **AgentService**: Enhanced to include role information when retrieving
agents
- **RoleResolver**: Added GraphQL mutations for `assignRoleToAgent` and
`removeRoleFromAgent`

### 3. AI Agent CRUD Operations
- **Permission-Based Tool Generation**: AI agents now receive database
tools based on their assigned role permissions
- **Dynamic Tool Creation**: The `AgentToolService` generates CRUD tools
(`create_*`, `find_*`, `update_*`, `soft_delete_*`, `destroy_*`) for
each object based on role permissions
- **Granular Permissions**: Supports both global role permissions
(`canReadAllObjectRecords`) and object-specific permissions
(`canReadObjectRecords`)

### 4. Frontend Integration
- **Role Assignment UI**: Added hooks and components for
assigning/removing roles from agents

## Demo


https://github.com/user-attachments/assets/41732267-742e-416c-b423-b687c2614c82

---------

Co-authored-by: Antoine Moreaux <moreaux.antoine@gmail.com>
Co-authored-by: Lucas Bordeau <bordeau.lucas@gmail.com>
Co-authored-by: Charles Bochet <charles@twenty.com>
Co-authored-by: Guillim <guillim@users.noreply.github.com>
Co-authored-by: Charles Bochet <charlesBochet@users.noreply.github.com>
Co-authored-by: Weiko <corentin@twenty.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@twenty.com>
Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
Co-authored-by: Marie <51697796+ijreilly@users.noreply.github.com>
Co-authored-by: martmull <martmull@hotmail.fr>
Co-authored-by: Thomas Trompette <thomas.trompette@sfr.fr>
Co-authored-by: Etienne <45695613+etiennejouan@users.noreply.github.com>
Co-authored-by: Baptiste Devessier <baptiste@devessier.fr>
Co-authored-by: nitin <142569587+ehconitin@users.noreply.github.com>
Co-authored-by: Paul Rastoin <45004772+prastoin@users.noreply.github.com>
Co-authored-by: prastoin <paul@twenty.com>
Co-authored-by: Vicky Wang <157669812+vickywxng@users.noreply.github.com>
Co-authored-by: Vicky Wang <vw92@cornell.edu>
Co-authored-by: Raphaël Bosi <71827178+bosiraphael@users.noreply.github.com>
This commit is contained in:
Abdul Rahman
2025-06-29 22:18:14 +02:00
committed by GitHub
co-authored by Antoine Moreaux Lucas Bordeau Charles Bochet Guillim Charles Bochet Weiko github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> github-actions Félix Malfait Marie martmull Thomas Trompette Etienne Baptiste Devessier nitin Paul Rastoin prastoin Vicky Wang Vicky Wang Raphaël Bosi
parent 317336ab71
commit 74b6466a57
53 changed files with 4804 additions and 478 deletions
@@ -0,0 +1,251 @@
import gql from 'graphql-tag';
import { AGENT_GQL_FIELDS } from 'test/integration/constants/agent-gql-fields.constants';
import { createAgentOperation } from 'test/integration/graphql/utils/create-agent-operation-factory.util';
import { deleteAgentOperation } from 'test/integration/graphql/utils/delete-agent-operation-factory.util';
import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util';
import { updateAgentOperation } from 'test/integration/graphql/utils/update-agent-operation-factory.util';
describe('agentResolver', () => {
describe('createOneAgent', () => {
it('should create an agent successfully', async () => {
const operation = createAgentOperation({
name: 'Test AI Agent Admin',
description: 'A test AI agent created by admin',
prompt: 'You are a helpful AI assistant for testing.',
modelId: 'gpt-4o',
responseFormat: { type: 'json_object' },
});
const response = await makeGraphqlAPIRequest(operation);
expect(response.body.data).toBeDefined();
expect(response.body.data.createOneAgent).toBeDefined();
expect(response.body.data.createOneAgent.id).toBeDefined();
expect(response.body.data.createOneAgent.name).toBe(
'Test AI Agent Admin',
);
expect(response.body.data.createOneAgent.description).toBe(
'A test AI agent created by admin',
);
expect(response.body.data.createOneAgent.prompt).toBe(
'You are a helpful AI assistant for testing.',
);
expect(response.body.data.createOneAgent.modelId).toBe('gpt-4o');
expect(response.body.data.createOneAgent.responseFormat).toEqual({
type: 'json_object',
});
await makeGraphqlAPIRequest(
deleteAgentOperation(response.body.data.createOneAgent.id),
);
});
it('should validate required fields and return error', async () => {
const operation = createAgentOperation({
name: undefined as any,
description: 'Agent without required fields',
prompt: undefined as any,
modelId: undefined as any,
});
const response = await makeGraphqlAPIRequest(operation);
expect(response.body.errors).toBeDefined();
expect(response.body.errors[0].message).toContain(
'Field "name" of required type "String!" was not provided',
);
});
});
describe('findOneAgent', () => {
let testAgentId: string;
beforeAll(async () => {
const operation = createAgentOperation({
name: 'Test Agent for Find',
description: 'A test agent for find operations',
prompt: 'You are a test agent for finding.',
modelId: 'gpt-4o',
});
const response = await makeGraphqlAPIRequest(operation);
testAgentId = response.body.data.createOneAgent.id;
});
afterAll(async () => {
await makeGraphqlAPIRequest(deleteAgentOperation(testAgentId));
});
it('should find agent by ID successfully', async () => {
const queryData = {
query: gql`
query FindOneAgent($input: AgentIdInput!) {
findOneAgent(input: $input) {
${AGENT_GQL_FIELDS}
}
}
`,
variables: { input: { id: testAgentId } },
};
const response = await makeGraphqlAPIRequest(queryData);
expect(response.status).toBe(200);
expect(response.body.errors).toBeUndefined();
expect(response.body.data.findOneAgent).toBeDefined();
expect(response.body.data.findOneAgent.id).toBe(testAgentId);
expect(response.body.data.findOneAgent.name).toBe('Test Agent for Find');
});
it('should return 404 error for non-existent agent', async () => {
const nonExistentId = '00000000-0000-0000-0000-000000000000';
const queryData = {
query: gql`
query FindOneAgent($input: AgentIdInput!) {
findOneAgent(input: $input) {
${AGENT_GQL_FIELDS}
}
}
`,
variables: { input: { id: nonExistentId } },
};
const response = await makeGraphqlAPIRequest(queryData);
expect(response.body.errors).toBeDefined();
expect(response.body.errors[0].message).toContain('not found');
});
});
describe('findManyAgents', () => {
const testAgentIds: string[] = [];
beforeAll(async () => {
for (let i = 0; i < 3; i++) {
const operation = createAgentOperation({
name: `Test Agent ${i + 1}`,
description: `A test agent ${i + 1} for find many operations`,
prompt: `You are test agent ${i + 1}.`,
modelId: 'gpt-4o',
});
const response = await makeGraphqlAPIRequest(operation);
testAgentIds.push(response.body.data.createOneAgent.id);
}
});
afterAll(async () => {
for (const agentId of testAgentIds) {
await makeGraphqlAPIRequest(deleteAgentOperation(agentId));
}
});
it('should find all agents successfully', async () => {
const queryData = {
query: gql`
query FindManyAgents {
findManyAgents {
${AGENT_GQL_FIELDS}
}
}
`,
};
const response = await makeGraphqlAPIRequest(queryData);
expect(response.status).toBe(200);
expect(response.body.errors).toBeUndefined();
expect(response.body.data.findManyAgents).toBeDefined();
expect(Array.isArray(response.body.data.findManyAgents)).toBe(true);
expect(response.body.data.findManyAgents.length).toBeGreaterThanOrEqual(
3,
);
const testAgentNames = response.body.data.findManyAgents
.filter((agent: any) => testAgentIds.includes(agent.id))
.map((agent: any) => agent.name);
expect(testAgentNames).toContain('Test Agent 1');
expect(testAgentNames).toContain('Test Agent 2');
expect(testAgentNames).toContain('Test Agent 3');
});
});
describe('updateOneAgent', () => {
let testAgentId: string;
beforeAll(async () => {
const operation = createAgentOperation({
name: 'Original Test Agent',
description: 'Original description',
prompt: 'Original prompt',
modelId: 'gpt-4o',
});
const response = await makeGraphqlAPIRequest(operation);
testAgentId = response.body.data.createOneAgent.id;
});
afterAll(async () => {
await makeGraphqlAPIRequest(deleteAgentOperation(testAgentId));
});
it('should update an agent successfully', async () => {
const operation = updateAgentOperation({
id: testAgentId,
name: 'Updated Test Agent Admin',
description: 'Updated description',
prompt: 'Updated prompt for admin',
modelId: 'gpt-4o-mini',
});
const response = await makeGraphqlAPIRequest(operation);
expect(response.body.data).toBeDefined();
expect(response.body.data.updateOneAgent).toBeDefined();
expect(response.body.data.updateOneAgent.id).toBe(testAgentId);
expect(response.body.data.updateOneAgent.name).toBe(
'Updated Test Agent Admin',
);
expect(response.body.data.updateOneAgent.description).toBe(
'Updated description',
);
expect(response.body.data.updateOneAgent.prompt).toBe(
'Updated prompt for admin',
);
expect(response.body.data.updateOneAgent.modelId).toBe('gpt-4o-mini');
});
});
describe('deleteOneAgent', () => {
let testAgentId: string;
beforeAll(async () => {
const operation = createAgentOperation({
name: 'Agent to Delete',
description: 'This agent will be deleted',
prompt: 'You are an agent that will be deleted.',
modelId: 'gpt-4o',
});
const response = await makeGraphqlAPIRequest(operation);
testAgentId = response.body.data.createOneAgent.id;
});
it('should delete an agent successfully', async () => {
const operation = deleteAgentOperation(testAgentId);
const response = await makeGraphqlAPIRequest(operation);
expect(response.body.data).toBeDefined();
expect(response.body.data.deleteOneAgent).toBeDefined();
expect(response.body.data.deleteOneAgent.id).toBe(testAgentId);
expect(response.body.data.deleteOneAgent.name).toBe('Agent to Delete');
const findQueryData = {
query: gql`
query FindOneAgent($input: AgentIdInput!) {
findOneAgent(input: $input) {
${AGENT_GQL_FIELDS}
}
}
`,
variables: { input: { id: testAgentId } },
};
const findResponse = await makeGraphqlAPIRequest(findQueryData);
expect(findResponse.body.errors).toBeDefined();
expect(findResponse.body.errors[0].message).toContain('not found');
});
it('should return 404 error for non-existent agent', async () => {
const nonExistentId = '00000000-0000-0000-0000-000000000000';
const operation = deleteAgentOperation(nonExistentId);
const response = await makeGraphqlAPIRequest(operation);
expect(response.body.errors).toBeDefined();
expect(response.body.errors[0].message).toContain('not found');
});
});
});
@@ -0,0 +1,33 @@
import gql from 'graphql-tag';
import { AGENT_GQL_FIELDS } from 'test/integration/constants/agent-gql-fields.constants';
export const createAgentOperation = ({
name,
description,
prompt,
modelId,
responseFormat,
}: {
name: string;
description?: string;
prompt: string;
modelId: string;
responseFormat?: object;
}) => ({
query: gql`
mutation CreateOneAgent($input: CreateAgentInput!) {
createOneAgent(input: $input) {
${AGENT_GQL_FIELDS}
}
}
`,
variables: {
input: {
name,
description,
prompt,
modelId,
responseFormat,
},
},
});
@@ -0,0 +1,15 @@
import gql from 'graphql-tag';
import { AGENT_GQL_FIELDS } from 'test/integration/constants/agent-gql-fields.constants';
export const deleteAgentOperation = (id: string) => ({
query: gql`
mutation DeleteOneAgent($input: AgentIdInput!) {
deleteOneAgent(input: $input) {
${AGENT_GQL_FIELDS}
}
}
`,
variables: {
input: { id },
},
});
@@ -0,0 +1,36 @@
import gql from 'graphql-tag';
import { AGENT_GQL_FIELDS } from 'test/integration/constants/agent-gql-fields.constants';
export const updateAgentOperation = ({
id,
name,
description,
prompt,
modelId,
responseFormat,
}: {
id: string;
name?: string;
description?: string;
prompt?: string;
modelId?: string;
responseFormat?: object;
}) => ({
query: gql`
mutation UpdateOneAgent($input: UpdateAgentInput!) {
updateOneAgent(input: $input) {
${AGENT_GQL_FIELDS}
}
}
`,
variables: {
input: {
id,
...(name && { name }),
...(description && { description }),
...(prompt && { prompt }),
...(modelId && { modelId }),
...(responseFormat && { responseFormat }),
},
},
});