Add Role tab to agent detail page and fix restricted fields permission issue (#15276)

This commit is contained in:
Abdul Rahman
2025-10-26 13:47:37 +05:30
committed by GitHub
parent 63c261645a
commit 6405097c6b
16 changed files with 597 additions and 109 deletions
@@ -6,10 +6,11 @@ import { CreateRecordService } from 'src/engine/core-modules/record-crud/service
import { DeleteRecordService } from 'src/engine/core-modules/record-crud/services/delete-record.service';
import { FindRecordsService } from 'src/engine/core-modules/record-crud/services/find-records.service';
import { UpdateRecordService } from 'src/engine/core-modules/record-crud/services/update-record.service';
import { generateCreateRecordInputSchema } from 'src/engine/core-modules/record-crud/utils/generate-create-record-input-schema.util';
import { generateUpdateRecordInputSchema } from 'src/engine/core-modules/record-crud/utils/generate-update-record-input-schema.util';
import { BulkDeleteToolInputSchema } from 'src/engine/core-modules/record-crud/zod-schemas/bulk-delete-tool.zod-schema';
import { generateFindToolInputSchema } from 'src/engine/core-modules/record-crud/zod-schemas/find-tool.zod-schema';
import { FindOneToolInputSchema } from 'src/engine/core-modules/record-crud/zod-schemas/find-one-tool.zod-schema';
import { generateRecordInputSchema } from 'src/engine/core-modules/record-crud/zod-schemas/record-input.zod-schema';
import { generateFindToolInputSchema } from 'src/engine/core-modules/record-crud/zod-schemas/find-tool.zod-schema';
import { SoftDeleteToolInputSchema } from 'src/engine/core-modules/record-crud/zod-schemas/soft-delete-tool.zod-schema';
import { isWorkflowRunObject } from 'src/engine/metadata-modules/agent/utils/is-workflow-run-object.util';
import { type ActorMetadata } from 'src/engine/metadata-modules/field-metadata/composite-types/actor.composite-type';
@@ -87,10 +88,15 @@ export class ToolService {
return;
}
const restrictedFields = objectPermission.restrictedFields;
if (objectPermission.canUpdateObjectRecords) {
tools[`create_${objectMetadata.nameSingular}`] = {
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.`,
inputSchema: generateRecordInputSchema(objectMetadata),
inputSchema: generateCreateRecordInputSchema(
objectMetadata,
restrictedFields,
),
execute: async (parameters) => {
return this.createRecordService.execute({
objectName: objectMetadata.nameSingular,
@@ -104,9 +110,18 @@ export class ToolService {
tools[`update_${objectMetadata.nameSingular}`] = {
description: `Update an existing ${objectMetadata.labelSingular} record. Provide the record ID and only the fields you want to change. Unspecified fields will remain unchanged. Returns the updated record with all current data.`,
inputSchema: generateRecordInputSchema(objectMetadata),
inputSchema: generateUpdateRecordInputSchema(
objectMetadata,
restrictedFields,
),
execute: async (parameters) => {
const { id, ...objectRecord } = parameters.input;
const { id, ...allFields } = parameters.input;
const objectRecord = Object.fromEntries(
Object.entries(allFields).filter(
([, value]) => value !== undefined,
),
);
return this.updateRecordService.execute({
objectName: objectMetadata.nameSingular,
@@ -122,7 +137,10 @@ export class ToolService {
if (objectPermission.canReadObjectRecords) {
tools[`find_${objectMetadata.nameSingular}`] = {
description: `Search for ${objectMetadata.labelSingular} records using flexible filtering criteria. Supports exact matches, pattern matching, ranges, and null checks. Use limit/offset for pagination and orderBy for sorting. Returns an array of matching records with their full data.`,
inputSchema: generateFindToolInputSchema(objectMetadata),
inputSchema: generateFindToolInputSchema(
objectMetadata,
restrictedFields,
),
execute: async (parameters) => {
const { limit, offset, orderBy, ...filter } = parameters.input;
@@ -8,6 +8,7 @@ import {
RecordCrudExceptionCode,
} from 'src/engine/core-modules/record-crud/exceptions/record-crud.exception';
import { type CreateRecordParams } from 'src/engine/core-modules/record-crud/types/create-record-params.type';
import { getSelectedColumnsFromRestrictedFields } from 'src/engine/core-modules/record-crud/utils/get-selected-columns-from-restricted-fields.util';
import { RecordPositionService } from 'src/engine/core-modules/record-position/services/record-position.service';
import { RecordInputTransformerService } from 'src/engine/core-modules/record-transformer/services/record-input-transformer.service';
import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type';
@@ -83,14 +84,28 @@ export class CreateRecordService {
objectMetadataMapItem: objectMetadataItemWithFieldsMaps,
});
const insertResult = await repository.insert({
...transformedObjectRecord,
position,
createdBy: params.createdBy ?? {
source: FieldActorSource.WORKFLOW,
name: 'Workflow',
const restrictedFields =
repository.objectRecordsPermissions?.[
objectMetadataItemWithFieldsMaps.id
]?.restrictedFields;
const selectedColumns = getSelectedColumnsFromRestrictedFields(
restrictedFields,
objectMetadataItemWithFieldsMaps,
);
const insertResult = await repository.insert(
{
...transformedObjectRecord,
position,
createdBy: params.createdBy ?? {
source: FieldActorSource.WORKFLOW,
name: 'Workflow',
},
},
});
undefined,
selectedColumns,
);
const [createdRecord] = insertResult.generatedMaps;
@@ -1,5 +1,6 @@
import { Injectable, Logger } from '@nestjs/common';
import isEmpty from 'lodash.isempty';
import { QUERY_MAX_RECORDS } from 'twenty-shared/constants';
import { OrderByDirection } from 'twenty-shared/types';
import { type ObjectLiteral } from 'typeorm';
@@ -10,9 +11,12 @@ import {
} from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
import { GraphqlQueryParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser';
import { getAllSelectableFields } from 'src/engine/api/utils/get-all-selectable-fields.utils';
import { type FindRecordsParams } from 'src/engine/core-modules/record-crud/types/find-records-params.type';
import { FindRecordsResult } from 'src/engine/core-modules/record-crud/types/find-records-result.type';
import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type';
import { type ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
import { type WorkspaceSelectQueryBuilder } from 'src/engine/twenty-orm/repository/workspace-select-query-builder';
import { type WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.repository';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { WorkflowCommonWorkspaceService } from 'src/modules/workflow/common/workspace-services/workflow-common.workspace-service';
@@ -75,6 +79,7 @@ export class FindRecordsService {
offset,
repository,
graphqlQueryParser,
objectMetadataItemWithFieldsMaps,
});
const totalCount = await this.getTotalCount({
@@ -82,6 +87,7 @@ export class FindRecordsService {
filter,
repository,
graphqlQueryParser,
objectMetadataItemWithFieldsMaps,
});
this.logger.log(`Found ${records.length} records in ${objectName}`);
@@ -106,6 +112,32 @@ export class FindRecordsService {
}
}
private applyRestrictedFieldsToQueryBuilder<T extends ObjectLiteral>(
queryBuilder: WorkspaceSelectQueryBuilder<T>,
repository: WorkspaceRepository<T>,
objectMetadataItemWithFieldsMaps: ObjectMetadataItemWithFieldMaps,
): WorkspaceSelectQueryBuilder<T> {
const restrictedFields =
repository.objectRecordsPermissions?.[objectMetadataItemWithFieldsMaps.id]
?.restrictedFields;
if (!restrictedFields || isEmpty(restrictedFields)) {
return queryBuilder;
}
const selectableFields = getAllSelectableFields({
restrictedFields,
objectMetadata: {
objectMetadataMapItem: objectMetadataItemWithFieldsMaps,
},
});
return queryBuilder.setFindOptions({
// @ts-expect-error - TypeORM typing limitation with dynamic select fields
select: selectableFields,
});
}
private async getObjectRecords<T extends ObjectLiteral>({
objectName,
filter,
@@ -114,6 +146,7 @@ export class FindRecordsService {
offset,
repository,
graphqlQueryParser,
objectMetadataItemWithFieldsMaps,
}: {
objectName: string;
filter:
@@ -125,6 +158,7 @@ export class FindRecordsService {
offset: number;
repository: WorkspaceRepository<T>;
graphqlQueryParser: GraphqlQueryParser;
objectMetadataItemWithFieldsMaps: ObjectMetadataItemWithFieldMaps;
}): Promise<T[]> {
const queryBuilder = repository.createQueryBuilder(objectName);
@@ -146,7 +180,13 @@ export class FindRecordsService {
false,
);
return withOrderByQueryBuilder
const queryBuilderWithSelect = this.applyRestrictedFieldsToQueryBuilder(
withOrderByQueryBuilder,
repository,
objectMetadataItemWithFieldsMaps,
);
return queryBuilderWithSelect
.skip(offset)
.take(limit ? Math.min(limit, QUERY_MAX_RECORDS) : QUERY_MAX_RECORDS)
.getMany();
@@ -157,6 +197,7 @@ export class FindRecordsService {
filter,
repository,
graphqlQueryParser,
objectMetadataItemWithFieldsMaps,
}: {
objectName: string;
filter:
@@ -165,6 +206,7 @@ export class FindRecordsService {
| undefined;
repository: WorkspaceRepository<ObjectLiteral>;
graphqlQueryParser: GraphqlQueryParser;
objectMetadataItemWithFieldsMaps: ObjectMetadataItemWithFieldMaps;
}): Promise<number> {
const countQueryBuilder = repository.createQueryBuilder(objectName);
@@ -180,6 +222,12 @@ export class FindRecordsService {
filter ?? {},
);
return withDeletedCountQueryBuilder.getCount();
const queryBuilderWithSelect = this.applyRestrictedFieldsToQueryBuilder(
withDeletedCountQueryBuilder,
repository,
objectMetadataItemWithFieldsMaps,
);
return queryBuilderWithSelect.getCount();
}
}
@@ -9,6 +9,7 @@ import {
RecordCrudExceptionCode,
} from 'src/engine/core-modules/record-crud/exceptions/record-crud.exception';
import { type UpdateRecordParams } from 'src/engine/core-modules/record-crud/types/update-record-params.type';
import { getSelectedColumnsFromRestrictedFields } from 'src/engine/core-modules/record-crud/utils/get-selected-columns-from-restricted-fields.util';
import { RecordInputTransformerService } from 'src/engine/core-modules/record-transformer/services/record-input-transformer.service';
import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
@@ -59,10 +60,27 @@ export class UpdateRecordService {
rolePermissionConfig,
);
const { objectMetadataItemWithFieldsMaps } =
await this.workflowCommonWorkspaceService.getObjectMetadataItemWithFieldsMaps(
objectName,
workspaceId,
);
const restrictedFields =
repository.objectRecordsPermissions?.[
objectMetadataItemWithFieldsMaps.id
]?.restrictedFields;
const selectedColumns = getSelectedColumnsFromRestrictedFields(
restrictedFields,
objectMetadataItemWithFieldsMaps,
);
const previousObjectRecord = await repository.findOne({
where: {
id: objectRecordId,
},
select: selectedColumns,
});
if (!previousObjectRecord) {
@@ -82,12 +100,6 @@ export class UpdateRecordService {
};
}
const { objectMetadataItemWithFieldsMaps } =
await this.workflowCommonWorkspaceService.getObjectMetadataItemWithFieldsMaps(
objectName,
workspaceId,
);
if (
!canObjectBeManagedByWorkflow({
nameSingular: objectMetadataItemWithFieldsMaps.nameSingular,
@@ -126,9 +138,14 @@ export class UpdateRecordService {
};
if (!deepEqual(updatedObjectRecord, previousObjectRecord)) {
await repository.update(objectRecordId, {
...transformedObjectRecord,
});
await repository.update(
objectRecordId,
{
...transformedObjectRecord,
},
undefined,
selectedColumns,
);
}
this.logger.log(`Record updated successfully in ${objectName}`);
@@ -8,6 +8,7 @@ import {
RecordCrudExceptionCode,
} from 'src/engine/core-modules/record-crud/exceptions/record-crud.exception';
import { UpsertRecordParams } from 'src/engine/core-modules/record-crud/types/upsert-record-params.type';
import { getSelectedColumnsFromRestrictedFields } from 'src/engine/core-modules/record-crud/utils/get-selected-columns-from-restricted-fields.util';
import { RecordInputTransformerService } from 'src/engine/core-modules/record-transformer/services/record-input-transformer.service';
import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type';
import { computeCompositeColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-column-name.util';
@@ -135,13 +136,28 @@ export class UpsertRecordService {
)
.filter(isDefined);
const upsertResult = await repository.upsert(transformedObjectRecord, {
conflictPaths: conflictPaths,
indexPredicate:
indexPredicate.length > 0
? `${indexPredicate.join(' AND ')}`
: undefined,
});
const restrictedFields =
repository.objectRecordsPermissions?.[
objectMetadataItemWithFieldsMaps.id
]?.restrictedFields;
const selectedColumns = getSelectedColumnsFromRestrictedFields(
restrictedFields,
objectMetadataItemWithFieldsMaps,
);
const upsertResult = await repository.upsert(
transformedObjectRecord,
{
conflictPaths: conflictPaths,
indexPredicate:
indexPredicate.length > 0
? `${indexPredicate.join(' AND ')}`
: undefined,
},
undefined,
selectedColumns,
);
const upsertedRecordId = upsertResult.identifiers?.[0].id;
@@ -156,6 +172,7 @@ export class UpsertRecordService {
where: {
id: upsertedRecordId,
},
select: selectedColumns,
});
if (!upsertedRecord) {
@@ -1,14 +1,17 @@
import { type RestrictedFieldsPermissions } from 'twenty-shared/types';
import { z } from 'zod';
import { generateRecordPropertiesZodSchema } from 'src/engine/core-modules/record-crud/zod-schemas/record-properties.zod-schema';
import { type ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
export const generateRecordInputSchema = (
export const generateCreateRecordInputSchema = (
objectMetadata: ObjectMetadataEntity,
restrictedFields?: RestrictedFieldsPermissions,
) => {
const recordPropertiesSchema = generateRecordPropertiesZodSchema(
objectMetadata,
false,
restrictedFields,
);
return z.object({
@@ -0,0 +1,33 @@
import { type RestrictedFieldsPermissions } from 'twenty-shared/types';
import { z } from 'zod';
import { generateRecordPropertiesZodSchema } from 'src/engine/core-modules/record-crud/zod-schemas/record-properties.zod-schema';
import { type ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
export const generateUpdateRecordInputSchema = (
objectMetadata: ObjectMetadataEntity,
restrictedFields?: RestrictedFieldsPermissions,
) => {
const recordPropertiesSchema = generateRecordPropertiesZodSchema(
objectMetadata,
false,
restrictedFields,
);
const updateSchema = recordPropertiesSchema.partial().extend({
id: z.string().uuid({
message:
'The unique identifier (UUID) of the record to update. This is required to identify which record should be modified.',
}),
});
return z.object({
loadingMessage: z
.string()
.optional()
.describe(
'A clear, human-readable description of the action being performed. Explain what operation you are executing and with what parameters in natural language.',
),
input: updateSchema,
});
};
@@ -0,0 +1,23 @@
import isEmpty from 'lodash.isempty';
import { type RestrictedFieldsPermissions } from 'twenty-shared/types';
import { getAllSelectableFields } from 'src/engine/api/utils/get-all-selectable-fields.utils';
import { type ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
export const getSelectedColumnsFromRestrictedFields = (
restrictedFields: RestrictedFieldsPermissions | undefined,
objectMetadataItemWithFieldsMaps: ObjectMetadataItemWithFieldMaps,
): string[] | undefined => {
if (!restrictedFields || isEmpty(restrictedFields)) {
return undefined;
}
const selectableFields = getAllSelectableFields({
restrictedFields,
objectMetadata: {
objectMetadataMapItem: objectMetadataItemWithFieldsMaps,
},
});
return Object.keys(selectableFields).filter((key) => selectableFields[key]);
};
@@ -1,12 +1,14 @@
import { type RestrictedFieldsPermissions } from 'twenty-shared/types';
import { z } from 'zod';
import { ObjectRecordOrderBySchema } from 'src/engine/core-modules/record-crud/zod-schemas/order-by.zod-schema';
import { generateFieldFilterZodSchema } from 'src/engine/core-modules/record-crud/zod-schemas/field-filters.zod-schema';
import { type ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { ObjectRecordOrderBySchema } from 'src/engine/core-modules/record-crud/zod-schemas/order-by.zod-schema';
import { shouldExcludeFieldFromAgentToolSchema } from 'src/engine/metadata-modules/field-metadata/utils/should-exclude-field-from-agent-tool-schema.util';
import { type ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
export const generateFindToolInputSchema = (
objectMetadata: ObjectMetadataEntity,
restrictedFields?: RestrictedFieldsPermissions,
) => {
const filterShape: Record<string, z.ZodTypeAny> = {};
@@ -15,6 +17,10 @@ export const generateFindToolInputSchema = (
return;
}
if (restrictedFields?.[field.id]?.canRead === false) {
return;
}
const filterSchema = generateFieldFilterZodSchema(field);
if (filterSchema) {
@@ -1,6 +1,9 @@
import { z } from 'zod';
import { FieldMetadataType } from 'twenty-shared/types';
import {
FieldMetadataType,
type RestrictedFieldsPermissions,
} from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { z } from 'zod';
import {
type FieldMetadataSettings,
@@ -74,6 +77,7 @@ const getFieldZodType = (field: FieldMetadataEntity): z.ZodTypeAny => {
export const generateRecordPropertiesZodSchema = (
objectMetadata: ObjectMetadataEntity,
forResponse = false,
restrictedFields?: RestrictedFieldsPermissions,
): z.ZodObject<Record<string, z.ZodTypeAny>> => {
const shape: Record<string, z.ZodTypeAny> = {};
@@ -85,6 +89,10 @@ export const generateRecordPropertiesZodSchema = (
return;
}
if (restrictedFields?.[field.id]?.canUpdate === false) {
return;
}
if (
isFieldMetadataEntityOfType(field, FieldMetadataType.RELATION) &&
field.settings?.relationType === RelationType.MANY_TO_ONE