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-30 01:48:14 +05:30
committed by GitHub
parent 317336ab71
commit 74b6466a57
53 changed files with 4804 additions and 478 deletions
@@ -0,0 +1,22 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AgentEntity } from 'src/engine/metadata-modules/agent/agent.entity';
import { AgentModule } from 'src/engine/metadata-modules/agent/agent.module';
import { RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity';
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
import { AgentRoleService } from './agent-role.service';
@Module({
imports: [
TypeOrmModule.forFeature(
[AgentEntity, RoleEntity, RoleTargetsEntity],
'core',
),
AgentModule,
],
providers: [AgentRoleService],
exports: [AgentRoleService],
})
export class AgentRoleModule {}
@@ -0,0 +1,364 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ModelId } from 'src/engine/core-modules/ai/constants/ai-models.const';
import { AgentEntity } from 'src/engine/metadata-modules/agent/agent.entity';
import {
AgentException,
AgentExceptionCode,
} from 'src/engine/metadata-modules/agent/agent.exception';
import { RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity';
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
import { AgentRoleService } from './agent-role.service';
describe('AgentRoleService', () => {
let service: AgentRoleService;
let agentRepository: Repository<AgentEntity>;
let roleRepository: Repository<RoleEntity>;
let roleTargetsRepository: Repository<RoleTargetsEntity>;
const testWorkspaceId = 'test-workspace-id';
let testAgent: AgentEntity;
let testRole: RoleEntity;
let testRole2: RoleEntity;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
AgentRoleService,
{
provide: getRepositoryToken(AgentEntity, 'core'),
useValue: {
findOne: jest.fn(),
save: jest.fn(),
},
},
{
provide: getRepositoryToken(RoleEntity, 'core'),
useValue: {
findOne: jest.fn(),
save: jest.fn(),
},
},
{
provide: getRepositoryToken(RoleTargetsEntity, 'core'),
useValue: {
findOne: jest.fn(),
save: jest.fn(),
delete: jest.fn(),
find: jest.fn(),
},
},
],
}).compile();
service = module.get<AgentRoleService>(AgentRoleService);
agentRepository = module.get<Repository<AgentEntity>>(
getRepositoryToken(AgentEntity, 'core'),
);
roleRepository = module.get<Repository<RoleEntity>>(
getRepositoryToken(RoleEntity, 'core'),
);
roleTargetsRepository = module.get<Repository<RoleTargetsEntity>>(
getRepositoryToken(RoleTargetsEntity, 'core'),
);
// Setup test data
testAgent = {
id: 'test-agent-id',
name: 'Test Agent',
description: 'Test agent for unit tests',
prompt: 'You are a test agent',
modelId: 'gpt-4o' as ModelId,
workspaceId: testWorkspaceId,
createdAt: new Date(),
updatedAt: new Date(),
} as AgentEntity;
testRole = {
id: 'test-role-id',
label: 'Test Role',
description: 'Test role for unit tests',
canUpdateAllSettings: false,
canReadAllObjectRecords: true,
canUpdateAllObjectRecords: false,
canSoftDeleteAllObjectRecords: false,
canDestroyAllObjectRecords: false,
workspaceId: testWorkspaceId,
createdAt: new Date(),
updatedAt: new Date(),
isEditable: true,
} as RoleEntity;
testRole2 = {
id: 'test-role-2-id',
label: 'Test Role 2',
description: 'Second test role for unit tests',
canUpdateAllSettings: true,
canReadAllObjectRecords: true,
canUpdateAllObjectRecords: true,
canSoftDeleteAllObjectRecords: false,
canDestroyAllObjectRecords: false,
workspaceId: testWorkspaceId,
createdAt: new Date(),
updatedAt: new Date(),
isEditable: true,
} as RoleEntity;
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('assignRoleToAgent', () => {
it('should successfully assign a role to an agent', async () => {
// Arrange
const newRoleTarget = {
id: 'new-role-target-id',
roleId: testRole.id,
agentId: testAgent.id,
workspaceId: testWorkspaceId,
createdAt: new Date(),
updatedAt: new Date(),
} as RoleTargetsEntity;
jest.spyOn(agentRepository, 'findOne').mockResolvedValue(testAgent);
jest.spyOn(roleRepository, 'findOne').mockResolvedValue(testRole);
jest.spyOn(roleTargetsRepository, 'findOne').mockResolvedValue(null);
jest
.spyOn(roleTargetsRepository, 'save')
.mockResolvedValue(newRoleTarget);
jest
.spyOn(roleTargetsRepository, 'delete')
.mockResolvedValue({ affected: 0 } as any);
// Act
await service.assignRoleToAgent({
workspaceId: testWorkspaceId,
agentId: testAgent.id,
roleId: testRole.id,
});
// Assert
expect(agentRepository.findOne).toHaveBeenCalledWith({
where: { id: testAgent.id, workspaceId: testWorkspaceId },
});
expect(roleRepository.findOne).toHaveBeenCalledWith({
where: { id: testRole.id, workspaceId: testWorkspaceId },
});
expect(roleTargetsRepository.findOne).toHaveBeenCalledWith({
where: {
agentId: testAgent.id,
roleId: testRole.id,
workspaceId: testWorkspaceId,
},
});
expect(roleTargetsRepository.save).toHaveBeenCalledWith({
roleId: testRole.id,
agentId: testAgent.id,
workspaceId: testWorkspaceId,
});
expect(roleTargetsRepository.delete).toHaveBeenCalledWith({
agentId: testAgent.id,
workspaceId: testWorkspaceId,
id: expect.any(Object), // Not(newRoleTarget.id)
});
});
it('should replace existing role when assigning a new role to an agent', async () => {
// Arrange
const newRoleTarget = {
id: 'new-role-target-id',
roleId: testRole2.id,
agentId: testAgent.id,
workspaceId: testWorkspaceId,
createdAt: new Date(),
updatedAt: new Date(),
} as RoleTargetsEntity;
jest.spyOn(agentRepository, 'findOne').mockResolvedValue(testAgent);
jest.spyOn(roleRepository, 'findOne').mockResolvedValue(testRole2);
jest.spyOn(roleTargetsRepository, 'findOne').mockResolvedValue(null);
jest
.spyOn(roleTargetsRepository, 'save')
.mockResolvedValue(newRoleTarget);
jest
.spyOn(roleTargetsRepository, 'delete')
.mockResolvedValue({ affected: 1 } as any);
// Act
await service.assignRoleToAgent({
workspaceId: testWorkspaceId,
agentId: testAgent.id,
roleId: testRole2.id,
});
// Assert
expect(roleTargetsRepository.save).toHaveBeenCalledWith({
roleId: testRole2.id,
agentId: testAgent.id,
workspaceId: testWorkspaceId,
});
expect(roleTargetsRepository.delete).toHaveBeenCalledWith({
agentId: testAgent.id,
workspaceId: testWorkspaceId,
id: expect.any(Object), // Not(newRoleTarget.id)
});
});
it('should not create duplicate role target when assigning the same role', async () => {
// Arrange
const existingRoleTarget = {
id: 'existing-role-target-id',
roleId: testRole.id,
agentId: testAgent.id,
workspaceId: testWorkspaceId,
createdAt: new Date(),
updatedAt: new Date(),
} as RoleTargetsEntity;
jest.spyOn(agentRepository, 'findOne').mockResolvedValue(testAgent);
jest.spyOn(roleRepository, 'findOne').mockResolvedValue(testRole);
jest
.spyOn(roleTargetsRepository, 'findOne')
.mockResolvedValue(existingRoleTarget);
// Act
await service.assignRoleToAgent({
workspaceId: testWorkspaceId,
agentId: testAgent.id,
roleId: testRole.id,
});
// Assert
expect(roleTargetsRepository.save).not.toHaveBeenCalled();
expect(roleTargetsRepository.delete).not.toHaveBeenCalled();
});
it('should throw AgentException when agent does not exist', async () => {
// Arrange
const nonExistentAgentId = 'non-existent-agent-id';
jest.spyOn(agentRepository, 'findOne').mockResolvedValue(null);
// Act & Assert
await expect(
service.assignRoleToAgent({
workspaceId: testWorkspaceId,
agentId: nonExistentAgentId,
roleId: testRole.id,
}),
).rejects.toThrow(AgentException);
await expect(
service.assignRoleToAgent({
workspaceId: testWorkspaceId,
agentId: nonExistentAgentId,
roleId: testRole.id,
}),
).rejects.toMatchObject({
code: AgentExceptionCode.AGENT_NOT_FOUND,
message: `Agent with id ${nonExistentAgentId} not found in workspace`,
});
});
it('should throw AgentException when role does not exist', async () => {
// Arrange
const nonExistentRoleId = 'non-existent-role-id';
jest.spyOn(agentRepository, 'findOne').mockResolvedValue(testAgent);
jest.spyOn(roleRepository, 'findOne').mockResolvedValue(null);
// Act & Assert
await expect(
service.assignRoleToAgent({
workspaceId: testWorkspaceId,
agentId: testAgent.id,
roleId: nonExistentRoleId,
}),
).rejects.toThrow(AgentException);
await expect(
service.assignRoleToAgent({
workspaceId: testWorkspaceId,
agentId: testAgent.id,
roleId: nonExistentRoleId,
}),
).rejects.toMatchObject({
code: AgentExceptionCode.AGENT_EXECUTION_FAILED,
message: `Role with id ${nonExistentRoleId} not found in workspace`,
});
});
it('should throw AgentException when agent belongs to different workspace', async () => {
// Arrange
const differentWorkspaceId = 'different-workspace-id';
jest.spyOn(agentRepository, 'findOne').mockResolvedValue(null);
// Act & Assert
await expect(
service.assignRoleToAgent({
workspaceId: differentWorkspaceId,
agentId: testAgent.id,
roleId: testRole.id,
}),
).rejects.toThrow(AgentException);
await expect(
service.assignRoleToAgent({
workspaceId: differentWorkspaceId,
agentId: testAgent.id,
roleId: testRole.id,
}),
).rejects.toMatchObject({
code: AgentExceptionCode.AGENT_NOT_FOUND,
message: `Agent with id ${testAgent.id} not found in workspace`,
});
});
});
describe('removeRoleFromAgent', () => {
it('should successfully remove role from agent', async () => {
// Arrange
jest
.spyOn(roleTargetsRepository, 'delete')
.mockResolvedValue({ affected: 1 } as any);
// Act
await service.removeRoleFromAgent({
workspaceId: testWorkspaceId,
agentId: testAgent.id,
});
// Assert
expect(roleTargetsRepository.delete).toHaveBeenCalledWith({
agentId: testAgent.id,
workspaceId: testWorkspaceId,
});
});
it('should not throw error when removing role from agent that has no role', async () => {
// Arrange
jest
.spyOn(roleTargetsRepository, 'delete')
.mockResolvedValue({ affected: 0 } as any);
// Act & Assert - Should not throw
await expect(
service.removeRoleFromAgent({
workspaceId: testWorkspaceId,
agentId: testAgent.id,
}),
).resolves.not.toThrow();
expect(roleTargetsRepository.delete).toHaveBeenCalledWith({
agentId: testAgent.id,
workspaceId: testWorkspaceId,
});
});
});
});
@@ -0,0 +1,113 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Not, Repository } from 'typeorm';
import { AgentEntity } from 'src/engine/metadata-modules/agent/agent.entity';
import {
AgentException,
AgentExceptionCode,
} from 'src/engine/metadata-modules/agent/agent.exception';
import { RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity';
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
@Injectable()
export class AgentRoleService {
constructor(
@InjectRepository(AgentEntity, 'core')
private readonly agentRepository: Repository<AgentEntity>,
@InjectRepository(RoleEntity, 'core')
private readonly roleRepository: Repository<RoleEntity>,
@InjectRepository(RoleTargetsEntity, 'core')
private readonly roleTargetsRepository: Repository<RoleTargetsEntity>,
) {}
public async assignRoleToAgent({
workspaceId,
agentId,
roleId,
}: {
workspaceId: string;
agentId: string;
roleId: string;
}): Promise<void> {
const validationResult = await this.validateAssignRoleInput({
agentId,
workspaceId,
roleId,
});
if (validationResult?.roleToAssignIsSameAsCurrentRole) {
return;
}
const newRoleTarget = await this.roleTargetsRepository.save({
roleId,
agentId,
workspaceId,
});
await this.roleTargetsRepository.delete({
agentId,
workspaceId,
id: Not(newRoleTarget.id),
});
}
public async removeRoleFromAgent({
workspaceId,
agentId,
}: {
workspaceId: string;
agentId: string;
}): Promise<void> {
await this.roleTargetsRepository.delete({
agentId,
workspaceId,
});
}
private async validateAssignRoleInput({
agentId,
workspaceId,
roleId,
}: {
agentId: string;
workspaceId: string;
roleId: string;
}) {
const agent = await this.agentRepository.findOne({
where: { id: agentId, workspaceId },
});
if (!agent) {
throw new AgentException(
`Agent with id ${agentId} not found in workspace`,
AgentExceptionCode.AGENT_NOT_FOUND,
);
}
const role = await this.roleRepository.findOne({
where: { id: roleId, workspaceId },
});
if (!role) {
throw new AgentException(
`Role with id ${roleId} not found in workspace`,
AgentExceptionCode.AGENT_EXECUTION_FAILED,
);
}
const existingRoleTarget = await this.roleTargetsRepository.findOne({
where: {
agentId,
roleId,
workspaceId,
},
});
return {
roleToAssignIsSameAsCurrentRole: Boolean(existingRoleTarget),
};
}
}