fix: exclude system objects and workflow/dashboard from AI/MCP write tool descriptors (#20973)
## Summary fix: exclude system join objects from AI/MCP create/update/delete tool descriptors Closes #20403 --- AI was used for assistance. --------- Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Co-authored-by: Félix Malfait <felix@twenty.com> Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
This commit is contained in:
+3
-4
@@ -1,7 +1,7 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { FieldActorSource } from 'twenty-shared/types';
|
||||
import { canObjectBeManagedByWorkflow } from 'twenty-shared/workflow';
|
||||
import { canObjectBeManagedByAutomation } from 'twenty-shared/workflow';
|
||||
|
||||
import { CommonCreateManyQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-create-many-query-runner/common-create-many-query-runner.service';
|
||||
import {
|
||||
@@ -38,13 +38,12 @@ export class CreateManyRecordsService {
|
||||
});
|
||||
|
||||
if (
|
||||
!canObjectBeManagedByWorkflow({
|
||||
!canObjectBeManagedByAutomation({
|
||||
nameSingular: flatObjectMetadata.nameSingular,
|
||||
isSystem: flatObjectMetadata.isSystem,
|
||||
})
|
||||
) {
|
||||
throw new RecordCrudException(
|
||||
'Failed to create: Object cannot be created by workflow',
|
||||
'Failed to create: Object cannot be created by automation',
|
||||
RecordCrudExceptionCode.INVALID_REQUEST,
|
||||
);
|
||||
}
|
||||
|
||||
+3
-4
@@ -1,7 +1,7 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { FieldActorSource } from 'twenty-shared/types';
|
||||
import { canObjectBeManagedByWorkflow } from 'twenty-shared/workflow';
|
||||
import { canObjectBeManagedByAutomation } from 'twenty-shared/workflow';
|
||||
|
||||
import { CommonCreateOneQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-create-one-query-runner.service';
|
||||
import {
|
||||
@@ -38,13 +38,12 @@ export class CreateRecordService {
|
||||
});
|
||||
|
||||
if (
|
||||
!canObjectBeManagedByWorkflow({
|
||||
!canObjectBeManagedByAutomation({
|
||||
nameSingular: flatObjectMetadata.nameSingular,
|
||||
isSystem: flatObjectMetadata.isSystem,
|
||||
})
|
||||
) {
|
||||
throw new RecordCrudException(
|
||||
'Failed to create: Object cannot be created by workflow',
|
||||
'Failed to create: Object cannot be created by automation',
|
||||
RecordCrudExceptionCode.INVALID_REQUEST,
|
||||
);
|
||||
}
|
||||
|
||||
+3
-4
@@ -1,7 +1,7 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { isDefined, isValidUuid } from 'twenty-shared/utils';
|
||||
import { canObjectBeManagedByWorkflow } from 'twenty-shared/workflow';
|
||||
import { canObjectBeManagedByAutomation } from 'twenty-shared/workflow';
|
||||
|
||||
import { CommonDeleteOneQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-delete-one-query-runner.service';
|
||||
import { CommonDestroyOneQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-destroy-one-query-runner.service';
|
||||
@@ -42,13 +42,12 @@ export class DeleteRecordService {
|
||||
});
|
||||
|
||||
if (
|
||||
!canObjectBeManagedByWorkflow({
|
||||
!canObjectBeManagedByAutomation({
|
||||
nameSingular: flatObjectMetadata.nameSingular,
|
||||
isSystem: flatObjectMetadata.isSystem,
|
||||
})
|
||||
) {
|
||||
throw new RecordCrudException(
|
||||
'Failed to delete: Object cannot be deleted by workflow',
|
||||
'Failed to delete: Object cannot be deleted by automation',
|
||||
RecordCrudExceptionCode.INVALID_REQUEST,
|
||||
);
|
||||
}
|
||||
|
||||
+3
-4
@@ -1,6 +1,6 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { canObjectBeManagedByWorkflow } from 'twenty-shared/workflow';
|
||||
import { canObjectBeManagedByAutomation } from 'twenty-shared/workflow';
|
||||
|
||||
import { CommonUpdateManyQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-update-many-query-runner.service';
|
||||
import {
|
||||
@@ -37,13 +37,12 @@ export class UpdateManyRecordsService {
|
||||
});
|
||||
|
||||
if (
|
||||
!canObjectBeManagedByWorkflow({
|
||||
!canObjectBeManagedByAutomation({
|
||||
nameSingular: flatObjectMetadata.nameSingular,
|
||||
isSystem: flatObjectMetadata.isSystem,
|
||||
})
|
||||
) {
|
||||
throw new RecordCrudException(
|
||||
'Failed to update: Object cannot be updated by workflow',
|
||||
'Failed to update: Object cannot be updated by automation',
|
||||
RecordCrudExceptionCode.INVALID_REQUEST,
|
||||
);
|
||||
}
|
||||
|
||||
+3
-4
@@ -1,7 +1,7 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { isDefined, isValidUuid } from 'twenty-shared/utils';
|
||||
import { canObjectBeManagedByWorkflow } from 'twenty-shared/workflow';
|
||||
import { canObjectBeManagedByAutomation } from 'twenty-shared/workflow';
|
||||
|
||||
import { CommonUpdateOneQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-update-one-query-runner.service';
|
||||
import {
|
||||
@@ -52,13 +52,12 @@ export class UpdateRecordService {
|
||||
});
|
||||
|
||||
if (
|
||||
!canObjectBeManagedByWorkflow({
|
||||
!canObjectBeManagedByAutomation({
|
||||
nameSingular: flatObjectMetadata.nameSingular,
|
||||
isSystem: flatObjectMetadata.isSystem,
|
||||
})
|
||||
) {
|
||||
throw new RecordCrudException(
|
||||
'Failed to update: Object cannot be updated by workflow',
|
||||
'Failed to update: Object cannot be updated by automation',
|
||||
RecordCrudExceptionCode.INVALID_REQUEST,
|
||||
);
|
||||
}
|
||||
|
||||
+3
-4
@@ -1,6 +1,6 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { canObjectBeManagedByWorkflow } from 'twenty-shared/workflow';
|
||||
import { canObjectBeManagedByAutomation } from 'twenty-shared/workflow';
|
||||
|
||||
import { CommonCreateOneQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-create-one-query-runner.service';
|
||||
import {
|
||||
@@ -32,13 +32,12 @@ export class UpsertRecordService {
|
||||
});
|
||||
|
||||
if (
|
||||
!canObjectBeManagedByWorkflow({
|
||||
!canObjectBeManagedByAutomation({
|
||||
nameSingular: flatObjectMetadata.nameSingular,
|
||||
isSystem: flatObjectMetadata.isSystem,
|
||||
})
|
||||
) {
|
||||
throw new RecordCrudException(
|
||||
'Failed to update: Object cannot be updated by workflow',
|
||||
'Failed to upsert: Object cannot be upserted by automation',
|
||||
RecordCrudExceptionCode.INVALID_REQUEST,
|
||||
);
|
||||
}
|
||||
|
||||
+174
@@ -0,0 +1,174 @@
|
||||
import { type ObjectPermissions } from 'twenty-shared/types';
|
||||
|
||||
import { DatabaseToolProvider } from 'src/engine/core-modules/tool-provider/providers/database-tool.provider';
|
||||
import { type ToolDescriptor } from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type';
|
||||
import { createEmptyFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/constant/create-empty-flat-entity-maps.constant';
|
||||
import { type WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
|
||||
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
|
||||
import { getFlatObjectMetadataMock } from 'src/engine/metadata-modules/flat-object-metadata/__mocks__/get-flat-object-metadata.mock';
|
||||
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
|
||||
import { type WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
|
||||
|
||||
const roleId = 'role-id';
|
||||
const workspaceId = 'workspace-id';
|
||||
|
||||
const allObjectPermissions: ObjectPermissions = {
|
||||
canReadObjectRecords: true,
|
||||
canUpdateObjectRecords: true,
|
||||
canSoftDeleteObjectRecords: true,
|
||||
canDestroyObjectRecords: true,
|
||||
restrictedFields: {},
|
||||
rowLevelPermissionPredicates: [],
|
||||
rowLevelPermissionPredicateGroups: [],
|
||||
};
|
||||
|
||||
const createFlatObject = (
|
||||
overrides: Partial<FlatObjectMetadata> &
|
||||
Pick<FlatObjectMetadata, 'nameSingular' | 'namePlural'>,
|
||||
) =>
|
||||
getFlatObjectMetadataMock({
|
||||
universalIdentifier: overrides.nameSingular,
|
||||
labelSingular: overrides.nameSingular,
|
||||
labelPlural: overrides.namePlural,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('DatabaseToolProvider', () => {
|
||||
const generateDescriptorNames = async (objects: FlatObjectMetadata[]) => {
|
||||
const flatObjectMetadataMaps =
|
||||
createEmptyFlatEntityMaps() as FlatEntityMaps<FlatObjectMetadata>;
|
||||
|
||||
for (const object of objects) {
|
||||
flatObjectMetadataMaps.byUniversalIdentifier[object.universalIdentifier] =
|
||||
object;
|
||||
flatObjectMetadataMaps.universalIdentifierById[object.id] =
|
||||
object.universalIdentifier;
|
||||
}
|
||||
|
||||
const workspaceCacheService = {
|
||||
getOrRecompute: jest.fn().mockResolvedValue({
|
||||
rolesPermissions: {
|
||||
[roleId]: Object.fromEntries(
|
||||
objects.map((object) => [object.id, allObjectPermissions]),
|
||||
),
|
||||
},
|
||||
}),
|
||||
} as unknown as WorkspaceCacheService;
|
||||
|
||||
const flatEntityMapsCacheService = {
|
||||
getOrRecomputeManyOrAllFlatEntityMaps: jest.fn().mockResolvedValue({
|
||||
flatObjectMetadataMaps,
|
||||
flatFieldMetadataMaps: createEmptyFlatEntityMaps(),
|
||||
}),
|
||||
} as unknown as WorkspaceManyOrAllFlatEntityMapsCacheService;
|
||||
|
||||
const provider = new DatabaseToolProvider(
|
||||
workspaceCacheService,
|
||||
flatEntityMapsCacheService,
|
||||
);
|
||||
|
||||
const descriptors = (await provider.generateDescriptors(
|
||||
{
|
||||
workspaceId,
|
||||
roleId,
|
||||
rolePermissionConfig: { unionOf: [roleId] },
|
||||
},
|
||||
{ includeSchemas: false },
|
||||
)) as ToolDescriptor[];
|
||||
|
||||
return descriptors.map((descriptor) => descriptor.name);
|
||||
};
|
||||
|
||||
it('advertises write tools for join/system objects allowed by automation', async () => {
|
||||
const descriptorNames = await generateDescriptorNames([
|
||||
createFlatObject({
|
||||
nameSingular: 'noteTarget',
|
||||
namePlural: 'noteTargets',
|
||||
isSystem: true,
|
||||
}),
|
||||
createFlatObject({
|
||||
nameSingular: 'taskTarget',
|
||||
namePlural: 'taskTargets',
|
||||
isSystem: true,
|
||||
}),
|
||||
createFlatObject({
|
||||
nameSingular: 'attachment',
|
||||
namePlural: 'attachments',
|
||||
isSystem: true,
|
||||
}),
|
||||
createFlatObject({
|
||||
nameSingular: 'timelineActivity',
|
||||
namePlural: 'timelineActivities',
|
||||
isSystem: true,
|
||||
}),
|
||||
createFlatObject({
|
||||
nameSingular: 'person',
|
||||
namePlural: 'people',
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(descriptorNames).toEqual(
|
||||
expect.arrayContaining([
|
||||
'create_note_target',
|
||||
'create_many_note_targets',
|
||||
'update_note_target',
|
||||
'update_many_note_targets',
|
||||
'delete_note_target',
|
||||
'create_task_target',
|
||||
'create_attachment',
|
||||
'create_timeline_activity',
|
||||
'create_person',
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not advertise write tools for objects blocked from automation', async () => {
|
||||
const descriptorNames = await generateDescriptorNames([
|
||||
createFlatObject({
|
||||
nameSingular: 'workspaceMember',
|
||||
namePlural: 'workspaceMembers',
|
||||
isSystem: true,
|
||||
}),
|
||||
createFlatObject({
|
||||
nameSingular: 'message',
|
||||
namePlural: 'messages',
|
||||
isSystem: true,
|
||||
}),
|
||||
createFlatObject({
|
||||
nameSingular: 'calendarEvent',
|
||||
namePlural: 'calendarEvents',
|
||||
isSystem: true,
|
||||
}),
|
||||
createFlatObject({
|
||||
nameSingular: 'dashboard',
|
||||
namePlural: 'dashboards',
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(descriptorNames).toEqual(
|
||||
expect.arrayContaining([
|
||||
'find_workspace_members',
|
||||
'find_messages',
|
||||
'find_calendar_events',
|
||||
'find_dashboards',
|
||||
]),
|
||||
);
|
||||
|
||||
expect(descriptorNames).toEqual(
|
||||
expect.not.arrayContaining([
|
||||
'create_workspace_member',
|
||||
'update_workspace_member',
|
||||
'delete_workspace_member',
|
||||
'create_message',
|
||||
'update_message',
|
||||
'delete_message',
|
||||
'create_calendar_event',
|
||||
'update_calendar_event',
|
||||
'delete_calendar_event',
|
||||
'create_dashboard',
|
||||
'update_dashboard',
|
||||
'delete_dashboard',
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
+6
-2
@@ -5,6 +5,7 @@ import {
|
||||
type ObjectsPermissionsByRoleId,
|
||||
} from 'twenty-shared/types';
|
||||
import { camelToSnakeCase, isDefined } from 'twenty-shared/utils';
|
||||
import { canObjectBeManagedByAutomation } from 'twenty-shared/workflow';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { type GenerateDescriptorOptions } from 'src/engine/core-modules/tool-provider/interfaces/generate-descriptor-options.type';
|
||||
@@ -116,6 +117,9 @@ export class DatabaseToolProvider implements ToolProvider {
|
||||
const restrictedFields = permission.restrictedFields;
|
||||
const snakePlural = camelToSnakeCase(objectMetadata.namePlural);
|
||||
const snakeSingular = camelToSnakeCase(objectMetadata.nameSingular);
|
||||
const canBeManagedByAutomation = canObjectBeManagedByAutomation({
|
||||
nameSingular: objectMetadata.nameSingular,
|
||||
});
|
||||
|
||||
if (permission.canReadObjectRecords) {
|
||||
descriptors.push({
|
||||
@@ -182,7 +186,7 @@ export class DatabaseToolProvider implements ToolProvider {
|
||||
}
|
||||
}
|
||||
|
||||
if (permission.canUpdateObjectRecords) {
|
||||
if (permission.canUpdateObjectRecords && canBeManagedByAutomation) {
|
||||
descriptors.push({
|
||||
name: `create_${snakeSingular}`,
|
||||
description: `Create a new ${objectMetadata.labelSingular} record. Provide all required fields and any optional fields you want to set. The system will automatically handle timestamps and IDs. Returns the created record with all its data.`,
|
||||
@@ -266,7 +270,7 @@ export class DatabaseToolProvider implements ToolProvider {
|
||||
});
|
||||
}
|
||||
|
||||
if (permission.canSoftDeleteObjectRecords) {
|
||||
if (permission.canSoftDeleteObjectRecords && canBeManagedByAutomation) {
|
||||
descriptors.push({
|
||||
name: `delete_${snakeSingular}`,
|
||||
description: `Delete a ${objectMetadata.labelSingular} record by marking it as deleted. The record is hidden from normal queries. This is reversible. Use this to remove records.`,
|
||||
|
||||
Reference in New Issue
Block a user