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

View File

@ -1,14 +1,11 @@
import { OpenAPIV3_1 } from 'openapi-types';
import { FieldMetadataType } from 'twenty-shared/types';
import { capitalize, isDefined } from 'twenty-shared/utils';
import { capitalize } from 'twenty-shared/utils';
import {
FieldMetadataSettings,
NumberDataType,
} from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-settings.interface';
import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface';
import { FieldMetadataDefaultValue } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-default-value.interface';
import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface';
import { generateRandomFieldValue } from 'src/engine/core-modules/open-api/utils/generate-random-field-value.utils';
import {
computeDepthParameters,
computeEndingBeforeParameters,
@ -18,11 +15,10 @@ import {
computeOrderByParameters,
computeStartingAfterParameters,
} from 'src/engine/core-modules/open-api/utils/parameters.utils';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { convertObjectMetadataToSchemaProperties } from 'src/engine/utils/convert-object-metadata-to-schema-properties.util';
import { isFieldMetadataEntityOfType } from 'src/engine/utils/is-field-metadata-of-type.util';
import { camelToTitleCase } from 'src/utils/camel-to-title-case';
import { generateRandomFieldValue } from 'src/engine/core-modules/open-api/utils/generate-random-field-value.utils';
type Property = OpenAPIV3_1.SchemaObject;
@ -32,65 +28,6 @@ type Properties = {
type OpenApiExample = Record<string, FieldMetadataDefaultValue>;
const isFieldAvailable = (field: FieldMetadataEntity, forResponse: boolean) => {
if (forResponse) {
return true;
}
switch (field.name) {
case 'id':
case 'createdAt':
case 'updatedAt':
case 'deletedAt':
return false;
default:
return true;
}
};
const getFieldProperties = (field: FieldMetadataEntity): Property => {
switch (field.type) {
case FieldMetadataType.UUID: {
return { type: 'string', format: 'uuid' };
}
case FieldMetadataType.TEXT:
case FieldMetadataType.RICH_TEXT: {
return { type: 'string' };
}
case FieldMetadataType.DATE_TIME: {
return { type: 'string', format: 'date-time' };
}
case FieldMetadataType.DATE: {
return { type: 'string', format: 'date' };
}
case FieldMetadataType.NUMBER: {
const settings =
field.settings as FieldMetadataSettings<FieldMetadataType.NUMBER>;
if (
settings?.dataType === NumberDataType.FLOAT ||
(isDefined(settings?.decimals) && settings.decimals > 0)
) {
return { type: 'number' };
}
return { type: 'integer' };
}
case FieldMetadataType.NUMERIC:
case FieldMetadataType.POSITION: {
return { type: 'number' };
}
case FieldMetadataType.BOOLEAN: {
return { type: 'boolean' };
}
case FieldMetadataType.RAW_JSON: {
return { type: 'object' };
}
default: {
return { type: 'string' };
}
}
};
const getSchemaComponentsExample = (
item: ObjectMetadataEntity,
): OpenApiExample => {
@ -132,261 +69,6 @@ const getSchemaComponentsExample = (
}, {});
};
const getSchemaComponentsProperties = ({
item,
forResponse,
}: {
item: ObjectMetadataEntity;
forResponse: boolean;
}): Properties => {
return item.fields.reduce((node, field) => {
if (
!isFieldAvailable(field, forResponse) ||
field.type === FieldMetadataType.TS_VECTOR
) {
return node;
}
if (
isFieldMetadataEntityOfType(field, FieldMetadataType.RELATION) &&
field.settings?.relationType === RelationType.MANY_TO_ONE
) {
return {
...node,
[`${field.name}Id`]: {
type: 'string',
format: 'uuid',
},
};
}
if (
isFieldMetadataEntityOfType(field, FieldMetadataType.RELATION) &&
field.settings?.relationType === RelationType.ONE_TO_MANY
) {
return node;
}
let itemProperty = {} as Property;
switch (field.type) {
case FieldMetadataType.MULTI_SELECT:
itemProperty = {
type: 'array',
items: {
type: 'string',
enum: field.options.map(
(option: { value: string }) => option.value,
),
},
};
break;
case FieldMetadataType.SELECT:
itemProperty = {
type: 'string',
enum: field.options.map((option: { value: string }) => option.value),
};
break;
case FieldMetadataType.ARRAY:
itemProperty = {
type: 'array',
items: {
type: 'string',
},
};
break;
case FieldMetadataType.RATING:
itemProperty = {
type: 'string',
enum: field.options.map((option: { value: string }) => option.value),
};
break;
case FieldMetadataType.LINKS:
itemProperty = {
type: 'object',
properties: {
primaryLinkLabel: {
type: 'string',
},
primaryLinkUrl: {
type: 'string',
},
secondaryLinks: {
type: 'array',
items: {
type: 'object',
description: 'A secondary link',
properties: {
url: {
type: 'string',
format: 'uri',
},
label: {
type: 'string',
},
},
},
},
},
};
break;
case FieldMetadataType.CURRENCY:
itemProperty = {
type: 'object',
properties: {
amountMicros: {
type: 'number',
},
currencyCode: {
type: 'string',
},
},
};
break;
case FieldMetadataType.FULL_NAME:
itemProperty = {
type: 'object',
properties: {
firstName: {
type: 'string',
},
lastName: {
type: 'string',
},
},
};
break;
case FieldMetadataType.ADDRESS:
itemProperty = {
type: 'object',
properties: {
addressStreet1: {
type: 'string',
},
addressStreet2: {
type: 'string',
},
addressCity: {
type: 'string',
},
addressPostcode: {
type: 'string',
},
addressState: {
type: 'string',
},
addressCountry: {
type: 'string',
},
addressLat: {
type: 'number',
},
addressLng: {
type: 'number',
},
},
};
break;
case FieldMetadataType.ACTOR:
itemProperty = {
type: 'object',
properties: {
source: {
type: 'string',
enum: [
'EMAIL',
'CALENDAR',
'WORKFLOW',
'API',
'IMPORT',
'MANUAL',
'SYSTEM',
'WEBHOOK',
],
},
...(forResponse
? {
workspaceMemberId: {
type: 'string',
format: 'uuid',
},
name: {
type: 'string',
},
}
: {}),
},
};
break;
case FieldMetadataType.EMAILS:
itemProperty = {
type: 'object',
properties: {
primaryEmail: {
type: 'string',
},
additionalEmails: {
type: 'array',
items: {
type: 'string',
format: 'email',
},
},
},
};
break;
case FieldMetadataType.PHONES:
itemProperty = {
properties: {
additionalPhones: {
type: 'array',
items: {
type: 'string',
},
},
primaryPhoneCountryCode: {
type: 'string',
},
primaryPhoneCallingCode: {
type: 'string',
},
primaryPhoneNumber: {
type: 'string',
},
},
type: 'object',
};
break;
case FieldMetadataType.RICH_TEXT_V2:
itemProperty = {
type: 'object',
properties: {
blocknote: {
type: 'string',
},
markdown: {
type: 'string',
},
},
};
break;
default:
itemProperty = getFieldProperties(field);
break;
}
if (field.description) {
itemProperty.description = field.description;
}
if (Object.keys(itemProperty).length) {
return { ...node, [field.name]: itemProperty };
}
return node;
}, {} as Properties);
};
const getSchemaComponentsRelationProperties = (
item: ObjectMetadataEntity,
): Properties => {
@ -461,7 +143,10 @@ const computeSchemaComponent = ({
const result: OpenAPIV3_1.SchemaObject = {
type: 'object',
description: item.description,
properties: getSchemaComponentsProperties({ item, forResponse }),
properties: convertObjectMetadataToSchemaProperties({
item,
forResponse,
}) as Properties,
...(!forResponse ? { example: getSchemaComponentsExample(item) } : {}),
};