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:
@@ -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 {}
|
||||
+364
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user