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