fix(ai-node) - scope AI agent node database tools to explicitly granted objects (#23400)
## Context An AI agent node scoped to a single object was still loading CRUD tools for the whole workspace, inflating every run's prompt to ~200k tokens (~110k on a standard seed workspace: 146 tools across 19 objects, 18 of them system objects). Two mechanisms caused this: the roles permissions cache force-grants every system object to every role (`isSystem ? true`), and blanket role flags (`canReadAllObjectRecords`, ...) grant all remaining objects. The per-object rows written by the agent Permissions tab were additive on top of that, so scoping an agent had almost no effect on its tool payload. ## What **Backend: explicit grants only for the agent node** - New opt-in flag `requireExplicitObjectGrants` on `ToolProviderContext`, set only by the workflow agent executor. - With the flag, `DatabaseToolProvider` generates CRUD tools exclusively from the role's explicit `objectPermission` rows: no row means no tools, and each verb gate reads the row directly (`canReadObjectRecords` for find tools, `canUpdateObjectRecords` for create/update/upsert, `canSoftDeleteObjectRecords` for delete). A verb left null is not granted; composed defaults and the system force-grant can no longer leak through. Composed permissions are still used for `restrictedFields`. - Explicit rows are read from the `flatObjectPermissionMaps` workspace cache key, fetched in the same `getOrRecompute` call as `rolesPermissions`: no extra query. - Without the flag (chat, MCP, tool index, workspace stats), behavior is unchanged: composed permissions, verified live (`getToolIndex` for an Admin returns the same 245 CRUD tools as before). - Removed the `CANNOT_ADD_OBJECT_PERMISSION_ON_SYSTEM_OBJECT` guard on `upsertObjectPermissions` so system objects can be granted explicitly. **Frontend: grant system objects from the agent Permissions tab** - The objects picker in the workflow agent side panel ends with a new "System objects" submenu listing all active system objects; picking one opens the same CRUD grant flow as regular objects. - Permissions granted on system objects now resolve their labels in the existing permission list and can be deleted (both previously looked up non-system objects only, which would have hidden such grants). Result: an agent granted one object ships ~10 tools instead of 146, cutting the prompt from ~110k tokens to a few thousand and the per-run cost accordingly. ## Notes - Removing the system-object guard affects the whole upsert path: user roles can also receive explicit system object rows via the API. A `canRead: false` row on a system object now takes effect at the query layer for that role. - The agent role is resolved as the first role of the permission config, matching `getObjectsPermissionsFromRolePermissionConfig` (multi-role is not supported yet). ## Tests - `database-tool.provider.spec.ts`: three new cases for the flag (object without a row emits nothing, partial row emits only granted verbs, absent flag keeps composed behavior even with zero rows, which guards the chat regression). - `object-permission.service.spec.ts`: the system-object case now asserts a successful upsert. - Integration: dropped the failing "system object" upsert case and its snapshot, added a successful system object upsert case. Both suites pass against a live server. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23400?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. -->
This commit is contained in:
+1
-1
@@ -33,7 +33,7 @@ export const WorkflowAiAgentPermissionList = ({
|
||||
onDeletePermission,
|
||||
searchQuery,
|
||||
}: WorkflowAiAgentPermissionListProps) => {
|
||||
const { alphaSortedActiveNonSystemObjectMetadataItems: objectMetadataItems } =
|
||||
const { activeObjectMetadataItems: objectMetadataItems } =
|
||||
useFilteredObjectMetadataItems();
|
||||
|
||||
const sortedExistingPermissions = objectPermissions
|
||||
|
||||
+16
-1
@@ -1,9 +1,13 @@
|
||||
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
|
||||
import { SidePanelGroup } from '@/side-panel/components/SidePanelGroup';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { IconSettings } from 'twenty-ui/icon';
|
||||
import { MenuItem } from 'twenty-ui/navigation';
|
||||
import { WorkflowAiAgentPermissionsObjectRow } from './WorkflowAiAgentPermissionsObjectRow';
|
||||
|
||||
type WorkflowAiAgentPermissionsObjectsListProps = {
|
||||
heading?: string;
|
||||
objects: Array<
|
||||
Pick<
|
||||
EnrichedObjectMetadataItem,
|
||||
@@ -11,16 +15,19 @@ type WorkflowAiAgentPermissionsObjectsListProps = {
|
||||
>
|
||||
>;
|
||||
onObjectClick: (objectId: string) => void;
|
||||
onSystemObjectsClick?: () => void;
|
||||
readonly: boolean;
|
||||
};
|
||||
|
||||
export const WorkflowAiAgentPermissionsObjectsList = ({
|
||||
heading,
|
||||
objects,
|
||||
onObjectClick,
|
||||
onSystemObjectsClick,
|
||||
readonly,
|
||||
}: WorkflowAiAgentPermissionsObjectsListProps) => {
|
||||
return (
|
||||
<SidePanelGroup heading={t`Objects`}>
|
||||
<SidePanelGroup heading={heading ?? t`Objects`}>
|
||||
{objects.map((objectMetadata) => (
|
||||
<WorkflowAiAgentPermissionsObjectRow
|
||||
key={objectMetadata.id}
|
||||
@@ -29,6 +36,14 @@ export const WorkflowAiAgentPermissionsObjectsList = ({
|
||||
readonly={readonly}
|
||||
/>
|
||||
))}
|
||||
{isDefined(onSystemObjectsClick) && (
|
||||
<MenuItem
|
||||
LeftIcon={IconSettings}
|
||||
text={t`System objects`}
|
||||
hasSubMenu={!readonly}
|
||||
onClick={!readonly ? onSystemObjectsClick : undefined}
|
||||
/>
|
||||
)}
|
||||
</SidePanelGroup>
|
||||
);
|
||||
};
|
||||
|
||||
+56
-18
@@ -8,6 +8,7 @@ import { type WorkflowAiAgentAction } from '@/workflow/types/Workflow';
|
||||
import { useWorkflowAiAgentPermissionActions } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/hooks/useWorkflowAiAgentPermissionActions';
|
||||
import { workflowAiAgentActionAgentState } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/states/workflowAiAgentActionAgentState';
|
||||
import { workflowAiAgentPermissionsIsAddingPermissionState } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/states/workflowAiAgentPermissionsIsAddingPermissionState';
|
||||
import { workflowAiAgentPermissionsIsSystemObjectsListOpenState } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/states/workflowAiAgentPermissionsIsSystemObjectsListOpenState';
|
||||
import { workflowAiAgentPermissionsSelectedObjectIdState } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/states/workflowAiAgentPermissionsSelectedObjectIdState';
|
||||
import { useQuery } from '@apollo/client/react';
|
||||
import { styled } from '@linaria/react';
|
||||
@@ -83,9 +84,21 @@ export const WorkflowAiAgentPermissionsTab = ({
|
||||
workflowAiAgentPermissionsIsAddingPermission,
|
||||
setWorkflowAiAgentPermissionsIsAddingPermission,
|
||||
] = useAtomState(workflowAiAgentPermissionsIsAddingPermissionState);
|
||||
const [
|
||||
workflowAiAgentPermissionsIsSystemObjectsListOpen,
|
||||
setWorkflowAiAgentPermissionsIsSystemObjectsListOpen,
|
||||
] = useAtomState(workflowAiAgentPermissionsIsSystemObjectsListOpenState);
|
||||
|
||||
const { alphaSortedActiveNonSystemObjectMetadataItems: objectMetadataItems } =
|
||||
useFilteredObjectMetadataItems();
|
||||
const {
|
||||
alphaSortedActiveNonSystemObjectMetadataItems: objectMetadataItems,
|
||||
objectMetadataItems: allObjectMetadataItems,
|
||||
} = useFilteredObjectMetadataItems();
|
||||
|
||||
const systemObjectMetadataItems = allObjectMetadataItems
|
||||
.filter((item) => item.isActive && item.isSystem)
|
||||
.sort((itemA, itemB) =>
|
||||
itemA.nameSingular.localeCompare(itemB.nameSingular),
|
||||
);
|
||||
|
||||
const {
|
||||
data: rolesData,
|
||||
@@ -114,6 +127,12 @@ export const WorkflowAiAgentPermissionsTab = ({
|
||||
getSearchableValues: (item) => [item.labelSingular, item.labelPlural],
|
||||
});
|
||||
|
||||
const filteredSystemObjects = filterBySearchQuery({
|
||||
items: systemObjectMetadataItems,
|
||||
searchQuery,
|
||||
getSearchableValues: (item) => [item.labelSingular, item.labelPlural],
|
||||
});
|
||||
|
||||
const {
|
||||
filteredPermissions: filteredActionPermissions,
|
||||
filteredEnabledPermissions: filteredEnabledActionPermissions,
|
||||
@@ -152,7 +171,7 @@ export const WorkflowAiAgentPermissionsTab = ({
|
||||
}
|
||||
|
||||
const selectedObject = isDefined(workflowAiAgentPermissionsSelectedObjectId)
|
||||
? objectMetadataItems.find(
|
||||
? allObjectMetadataItems.find(
|
||||
(item) => item.id === workflowAiAgentPermissionsSelectedObjectId,
|
||||
)
|
||||
: undefined;
|
||||
@@ -164,10 +183,17 @@ export const WorkflowAiAgentPermissionsTab = ({
|
||||
: undefined;
|
||||
|
||||
const handleBack = () => {
|
||||
isDefined(workflowAiAgentPermissionsSelectedObjectId) &&
|
||||
if (isDefined(workflowAiAgentPermissionsSelectedObjectId)) {
|
||||
setWorkflowAiAgentPermissionsSelectedObjectId(undefined);
|
||||
!isDefined(workflowAiAgentPermissionsSelectedObjectId) &&
|
||||
setWorkflowAiAgentPermissionsIsAddingPermission(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (workflowAiAgentPermissionsIsSystemObjectsListOpen) {
|
||||
setWorkflowAiAgentPermissionsIsSystemObjectsListOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setWorkflowAiAgentPermissionsIsAddingPermission(false);
|
||||
};
|
||||
|
||||
const handleObjectClick = (objectId: string) => {
|
||||
@@ -176,6 +202,7 @@ export const WorkflowAiAgentPermissionsTab = ({
|
||||
|
||||
const shouldShowBackButton =
|
||||
isDefined(workflowAiAgentPermissionsSelectedObjectId) ||
|
||||
workflowAiAgentPermissionsIsSystemObjectsListOpen ||
|
||||
workflowAiAgentPermissionsIsAddingPermission;
|
||||
const shouldShowCrudList = isDefined(selectedObject);
|
||||
const shouldShowSelectionLists =
|
||||
@@ -227,22 +254,33 @@ export const WorkflowAiAgentPermissionsTab = ({
|
||||
/>
|
||||
)}
|
||||
|
||||
{shouldShowSelectionLists && (
|
||||
<>
|
||||
{shouldShowSelectionLists &&
|
||||
(workflowAiAgentPermissionsIsSystemObjectsListOpen ? (
|
||||
<WorkflowAiAgentPermissionsObjectsList
|
||||
objects={filteredObjects}
|
||||
heading={t`System objects`}
|
||||
objects={filteredSystemObjects}
|
||||
onObjectClick={handleObjectClick}
|
||||
readonly={readonly}
|
||||
/>
|
||||
<WorkflowAiAgentPermissionsFlagList
|
||||
title={t`Actions`}
|
||||
permissions={filteredActionPermissions}
|
||||
enabledPermissionFlagKeys={permissionFlagKeys}
|
||||
readonly={readonly}
|
||||
onAddPermissionFlag={handleAddPermissionFlag}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
) : (
|
||||
<>
|
||||
<WorkflowAiAgentPermissionsObjectsList
|
||||
objects={filteredObjects}
|
||||
onObjectClick={handleObjectClick}
|
||||
onSystemObjectsClick={() =>
|
||||
setWorkflowAiAgentPermissionsIsSystemObjectsListOpen(true)
|
||||
}
|
||||
readonly={readonly}
|
||||
/>
|
||||
<WorkflowAiAgentPermissionsFlagList
|
||||
title={t`Actions`}
|
||||
permissions={filteredActionPermissions}
|
||||
enabledPermissionFlagKeys={permissionFlagKeys}
|
||||
readonly={readonly}
|
||||
onAddPermissionFlag={handleAddPermissionFlag}
|
||||
/>
|
||||
</>
|
||||
))}
|
||||
|
||||
{shouldShowExistingPermissions && (
|
||||
<>
|
||||
|
||||
+5
@@ -2,6 +2,7 @@ import { useListenToSidePanelClosing } from '@/ui/layout/side-panel/hooks/useLis
|
||||
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
|
||||
import { workflowAiAgentActionAgentState } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/states/workflowAiAgentActionAgentState';
|
||||
import { workflowAiAgentPermissionsIsAddingPermissionState } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/states/workflowAiAgentPermissionsIsAddingPermissionState';
|
||||
import { workflowAiAgentPermissionsIsSystemObjectsListOpenState } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/states/workflowAiAgentPermissionsIsSystemObjectsListOpenState';
|
||||
import { workflowAiAgentPermissionsSelectedObjectIdState } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/states/workflowAiAgentPermissionsSelectedObjectIdState';
|
||||
|
||||
export const useResetWorkflowAiAgentPermissionsStateOnSidePanelClose = () => {
|
||||
@@ -11,6 +12,9 @@ export const useResetWorkflowAiAgentPermissionsStateOnSidePanelClose = () => {
|
||||
const setWorkflowAiAgentPermissionsIsAddingPermission = useSetAtomState(
|
||||
workflowAiAgentPermissionsIsAddingPermissionState,
|
||||
);
|
||||
const setWorkflowAiAgentPermissionsIsSystemObjectsListOpen = useSetAtomState(
|
||||
workflowAiAgentPermissionsIsSystemObjectsListOpenState,
|
||||
);
|
||||
const setWorkflowAiAgentActionAgent = useSetAtomState(
|
||||
workflowAiAgentActionAgentState,
|
||||
);
|
||||
@@ -18,6 +22,7 @@ export const useResetWorkflowAiAgentPermissionsStateOnSidePanelClose = () => {
|
||||
const resetPermissionState = () => {
|
||||
setWorkflowAiAgentPermissionsSelectedObjectId(undefined);
|
||||
setWorkflowAiAgentPermissionsIsAddingPermission(false);
|
||||
setWorkflowAiAgentPermissionsIsSystemObjectsListOpen(false);
|
||||
setWorkflowAiAgentActionAgent(undefined);
|
||||
};
|
||||
|
||||
|
||||
+7
-1
@@ -7,6 +7,7 @@ import { CRUD_PERMISSIONS } from '@/workflow/workflow-steps/workflow-actions/ai-
|
||||
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
|
||||
import { workflowAiAgentActionAgentState } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/states/workflowAiAgentActionAgentState';
|
||||
import { workflowAiAgentPermissionsIsAddingPermissionState } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/states/workflowAiAgentPermissionsIsAddingPermissionState';
|
||||
import { workflowAiAgentPermissionsIsSystemObjectsListOpenState } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/states/workflowAiAgentPermissionsIsSystemObjectsListOpenState';
|
||||
import { workflowAiAgentPermissionsSelectedObjectIdState } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/states/workflowAiAgentPermissionsSelectedObjectIdState';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { useMemo } from 'react';
|
||||
@@ -38,7 +39,7 @@ export const useWorkflowAiAgentPermissionActions = ({
|
||||
const { enqueueSuccessSnackBar } = useSnackBar();
|
||||
const [workflowAiAgentActionAgent, setWorkflowAiAgentActionAgent] =
|
||||
useAtomState(workflowAiAgentActionAgentState);
|
||||
const { alphaSortedActiveNonSystemObjectMetadataItems: objectMetadataItems } =
|
||||
const { activeObjectMetadataItems: objectMetadataItems } =
|
||||
useFilteredObjectMetadataItems();
|
||||
const settingsPermissionsConfig = useSettingsRolePermissionFlagConfig({
|
||||
assignmentCapabilities: { canBeAssignedToAgents: true },
|
||||
@@ -53,6 +54,9 @@ export const useWorkflowAiAgentPermissionActions = ({
|
||||
const [, setWorkflowAiAgentPermissionsIsAddingPermission] = useAtomState(
|
||||
workflowAiAgentPermissionsIsAddingPermissionState,
|
||||
);
|
||||
const [, setWorkflowAiAgentPermissionsIsSystemObjectsListOpen] = useAtomState(
|
||||
workflowAiAgentPermissionsIsSystemObjectsListOpenState,
|
||||
);
|
||||
|
||||
const [createRole] = useMutation(CreateOneRoleDocument);
|
||||
const [assignRoleToAgent] = useMutation(AssignRoleToAgentDocument);
|
||||
@@ -201,6 +205,7 @@ export const useWorkflowAiAgentPermissionActions = ({
|
||||
await refetchAgentAndRoles();
|
||||
setWorkflowAiAgentPermissionsIsAddingPermission(false);
|
||||
setWorkflowAiAgentPermissionsSelectedObjectId(undefined);
|
||||
setWorkflowAiAgentPermissionsIsSystemObjectsListOpen(false);
|
||||
};
|
||||
|
||||
const handleDeletePermission = async (
|
||||
@@ -318,6 +323,7 @@ export const useWorkflowAiAgentPermissionActions = ({
|
||||
await refetchAgentAndRoles();
|
||||
setWorkflowAiAgentPermissionsIsAddingPermission(false);
|
||||
setWorkflowAiAgentPermissionsSelectedObjectId(undefined);
|
||||
setWorkflowAiAgentPermissionsIsSystemObjectsListOpen(false);
|
||||
};
|
||||
|
||||
const handleDeletePermissionFlag = async (permissionFlagKey: string) => {
|
||||
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
|
||||
|
||||
export const workflowAiAgentPermissionsIsSystemObjectsListOpenState =
|
||||
createAtomState<boolean>({
|
||||
key: 'workflowAiAgentPermissionsIsSystemObjectsListOpenState',
|
||||
defaultValue: false,
|
||||
});
|
||||
+1
@@ -16,4 +16,5 @@ export type ToolProviderContext = {
|
||||
threadId?: string;
|
||||
locale?: keyof typeof APP_LOCALES;
|
||||
onCodeExecutionUpdate?: CodeExecutionStreamEmitter;
|
||||
requireExplicitObjectGrants?: boolean;
|
||||
};
|
||||
|
||||
+95
-3
@@ -35,8 +35,23 @@ const createFlatObject = (
|
||||
...overrides,
|
||||
});
|
||||
|
||||
type ExplicitPermissionRow = {
|
||||
objectMetadataId: string;
|
||||
canReadObjectRecords?: boolean;
|
||||
canUpdateObjectRecords?: boolean;
|
||||
canSoftDeleteObjectRecords?: boolean;
|
||||
};
|
||||
|
||||
type GenerateDescriptorsTestOptions = {
|
||||
requireExplicitObjectGrants?: boolean;
|
||||
explicitPermissionRows?: ExplicitPermissionRow[];
|
||||
};
|
||||
|
||||
describe('DatabaseToolProvider', () => {
|
||||
const generateDescriptors = async (objects: FlatObjectMetadata[]) => {
|
||||
const generateDescriptors = async (
|
||||
objects: FlatObjectMetadata[],
|
||||
options?: GenerateDescriptorsTestOptions,
|
||||
) => {
|
||||
const flatObjectMetadataMaps =
|
||||
createEmptyFlatEntityMaps() as FlatEntityMaps<FlatObjectMetadata>;
|
||||
|
||||
@@ -47,6 +62,15 @@ describe('DatabaseToolProvider', () => {
|
||||
object.universalIdentifier;
|
||||
}
|
||||
|
||||
const explicitPermissionRows =
|
||||
options?.explicitPermissionRows ??
|
||||
objects.map((object) => ({
|
||||
objectMetadataId: object.id,
|
||||
canReadObjectRecords: true,
|
||||
canUpdateObjectRecords: true,
|
||||
canSoftDeleteObjectRecords: true,
|
||||
}));
|
||||
|
||||
const workspaceCacheService = {
|
||||
getOrRecompute: jest.fn().mockResolvedValue({
|
||||
rolesPermissions: {
|
||||
@@ -54,6 +78,14 @@ describe('DatabaseToolProvider', () => {
|
||||
objects.map((object) => [object.id, allObjectPermissions]),
|
||||
),
|
||||
},
|
||||
flatObjectPermissionMaps: {
|
||||
byUniversalIdentifier: Object.fromEntries(
|
||||
explicitPermissionRows.map((row, index) => [
|
||||
`object-permission-${index}`,
|
||||
{ roleId, ...row },
|
||||
]),
|
||||
),
|
||||
},
|
||||
}),
|
||||
} as unknown as WorkspaceCacheService;
|
||||
|
||||
@@ -90,13 +122,17 @@ describe('DatabaseToolProvider', () => {
|
||||
workspaceId,
|
||||
roleId,
|
||||
rolePermissionConfig: { unionOf: [roleId] },
|
||||
requireExplicitObjectGrants: options?.requireExplicitObjectGrants,
|
||||
},
|
||||
{ includeSchemas: false },
|
||||
)) as (ToolIndexEntry | ToolDescriptor)[];
|
||||
};
|
||||
|
||||
const generateDescriptorNames = async (objects: FlatObjectMetadata[]) => {
|
||||
const descriptors = await generateDescriptors(objects);
|
||||
const generateDescriptorNames = async (
|
||||
objects: FlatObjectMetadata[],
|
||||
options?: GenerateDescriptorsTestOptions,
|
||||
) => {
|
||||
const descriptors = await generateDescriptors(objects, options);
|
||||
|
||||
return descriptors.map((descriptor) => descriptor.name);
|
||||
};
|
||||
@@ -251,4 +287,60 @@ describe('DatabaseToolProvider', () => {
|
||||
expect(descriptor.label.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
describe('requireExplicitObjectGrants', () => {
|
||||
const personObject = createFlatObject({
|
||||
nameSingular: 'person',
|
||||
namePlural: 'people',
|
||||
});
|
||||
const companyObject = createFlatObject({
|
||||
nameSingular: 'company',
|
||||
namePlural: 'companies',
|
||||
});
|
||||
|
||||
it('emits no tools for objects without an explicit permission row', async () => {
|
||||
const descriptorNames = await generateDescriptorNames(
|
||||
[personObject, companyObject],
|
||||
{
|
||||
requireExplicitObjectGrants: true,
|
||||
explicitPermissionRows: [
|
||||
{
|
||||
objectMetadataId: personObject.id,
|
||||
canReadObjectRecords: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
expect(descriptorNames).toContain('find_many_people');
|
||||
expect(descriptorNames).not.toContain('find_many_companies');
|
||||
});
|
||||
|
||||
it('emits only the verbs granted by the explicit row', async () => {
|
||||
const descriptorNames = await generateDescriptorNames([personObject], {
|
||||
requireExplicitObjectGrants: true,
|
||||
explicitPermissionRows: [
|
||||
{
|
||||
objectMetadataId: personObject.id,
|
||||
canReadObjectRecords: true,
|
||||
canUpdateObjectRecords: false,
|
||||
canSoftDeleteObjectRecords: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(descriptorNames).toContain('find_many_people');
|
||||
expect(descriptorNames).not.toContain('create_one_person');
|
||||
expect(descriptorNames).not.toContain('delete_one_person');
|
||||
});
|
||||
|
||||
it('keeps composed permissions when the flag is not set even without explicit rows', async () => {
|
||||
const descriptorNames = await generateDescriptorNames([personObject], {
|
||||
explicitPermissionRows: [],
|
||||
});
|
||||
|
||||
expect(descriptorNames).toContain('find_many_people');
|
||||
expect(descriptorNames).toContain('create_one_person');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+52
-5
@@ -27,8 +27,10 @@ import { type ToolDescriptor } from 'src/engine/core-modules/tool-provider/types
|
||||
import { type ToolIndexEntry } from 'src/engine/core-modules/tool-provider/types/tool-index-entry.type';
|
||||
import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type';
|
||||
import { getDatabaseCrudToolFlatObjects } from 'src/engine/metadata-modules/ai/ai-agent/utils/get-database-crud-tool-flat-objects.util';
|
||||
import { type FlatObjectPermission } from 'src/engine/metadata-modules/flat-object-permission/types/flat-object-permission.type';
|
||||
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
|
||||
import { getObjectsPermissionsFromRolePermissionConfig } from 'src/engine/twenty-orm/utils/get-objects-permissions-from-role-permission-config.util';
|
||||
import { getRoleIdsFromRolePermissionConfig } from 'src/engine/twenty-orm/utils/get-role-ids-from-role-permission-config.util';
|
||||
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
|
||||
import { ToolCategory } from 'twenty-shared/ai';
|
||||
|
||||
@@ -68,9 +70,10 @@ export class DatabaseToolProvider implements ToolProvider {
|
||||
const toolNames = options?.toolNames;
|
||||
const descriptors: (ToolIndexEntry | ToolDescriptor)[] = [];
|
||||
|
||||
const { rolesPermissions } =
|
||||
const { rolesPermissions, flatObjectPermissionMaps } =
|
||||
await this.workspaceCacheService.getOrRecompute(context.workspaceId, [
|
||||
'rolesPermissions',
|
||||
'flatObjectPermissionMaps',
|
||||
]);
|
||||
|
||||
const objectPermissions = getObjectsPermissionsFromRolePermissionConfig({
|
||||
@@ -82,6 +85,34 @@ export class DatabaseToolProvider implements ToolProvider {
|
||||
return descriptors;
|
||||
}
|
||||
|
||||
const requireExplicitObjectGrants =
|
||||
context.requireExplicitObjectGrants === true;
|
||||
|
||||
const roleId = getRoleIdsFromRolePermissionConfig(
|
||||
context.rolePermissionConfig,
|
||||
)[0];
|
||||
|
||||
const explicitPermissionByObjectId = new Map<
|
||||
string,
|
||||
FlatObjectPermission
|
||||
>();
|
||||
|
||||
if (requireExplicitObjectGrants) {
|
||||
for (const flatObjectPermission of Object.values(
|
||||
flatObjectPermissionMaps.byUniversalIdentifier,
|
||||
)) {
|
||||
if (
|
||||
isDefined(flatObjectPermission) &&
|
||||
flatObjectPermission.roleId === roleId
|
||||
) {
|
||||
explicitPermissionByObjectId.set(
|
||||
flatObjectPermission.objectMetadataId,
|
||||
flatObjectPermission,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const { flatObjectMetadataMaps, flatFieldMetadataMaps } =
|
||||
await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
|
||||
{
|
||||
@@ -96,11 +127,27 @@ export class DatabaseToolProvider implements ToolProvider {
|
||||
|
||||
for (const flatObject of allFlatObjects) {
|
||||
const permission = objectPermissions[flatObject.id];
|
||||
const explicitPermission = explicitPermissionByObjectId.get(
|
||||
flatObject.id,
|
||||
);
|
||||
|
||||
if (!permission) {
|
||||
if (
|
||||
!permission ||
|
||||
(requireExplicitObjectGrants && !isDefined(explicitPermission))
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const canReadRecords = requireExplicitObjectGrants
|
||||
? explicitPermission?.canReadObjectRecords === true
|
||||
: permission.canReadObjectRecords;
|
||||
const canUpdateRecords = requireExplicitObjectGrants
|
||||
? explicitPermission?.canUpdateObjectRecords === true
|
||||
: permission.canUpdateObjectRecords;
|
||||
const canSoftDeleteRecords = requireExplicitObjectGrants
|
||||
? explicitPermission?.canSoftDeleteObjectRecords === true
|
||||
: permission.canSoftDeleteObjectRecords;
|
||||
|
||||
const snakePlural = camelToSnakeCase(flatObject.namePlural);
|
||||
const snakeSingular = camelToSnakeCase(flatObject.nameSingular);
|
||||
|
||||
@@ -125,7 +172,7 @@ export class DatabaseToolProvider implements ToolProvider {
|
||||
const shouldIncludeSchema = (name: string) =>
|
||||
includeSchemas && (!toolNames || toolNames.has(name));
|
||||
|
||||
if (permission.canReadObjectRecords) {
|
||||
if (canReadRecords) {
|
||||
descriptors.push({
|
||||
name: `find_many_${snakePlural}`,
|
||||
...getCrudToolLabels(
|
||||
@@ -212,7 +259,7 @@ export class DatabaseToolProvider implements ToolProvider {
|
||||
}
|
||||
}
|
||||
|
||||
if (permission.canUpdateObjectRecords && canBeManagedByAutomation) {
|
||||
if (canUpdateRecords && canBeManagedByAutomation) {
|
||||
descriptors.push({
|
||||
name: `create_one_${snakeSingular}`,
|
||||
...getCrudToolLabels(
|
||||
@@ -348,7 +395,7 @@ export class DatabaseToolProvider implements ToolProvider {
|
||||
});
|
||||
}
|
||||
|
||||
if (permission.canSoftDeleteObjectRecords) {
|
||||
if (canSoftDeleteRecords) {
|
||||
descriptors.push({
|
||||
name: `delete_one_${snakeSingular}`,
|
||||
...getCrudToolLabels(
|
||||
|
||||
+1
@@ -186,6 +186,7 @@ export class AgentAsyncExecutorService {
|
||||
workspaceId: agent.workspaceId,
|
||||
roleId: agentRoleId,
|
||||
rolePermissionConfig: agentRolePermissionConfig,
|
||||
requireExplicitObjectGrants: true,
|
||||
authContext,
|
||||
actorContext,
|
||||
userId:
|
||||
|
||||
+45
-16
@@ -94,7 +94,7 @@ describe('ObjectPermissionService', () => {
|
||||
const systemObjectMetadataId = 'system-object-id';
|
||||
const customObjectMetadataId = 'custom-object-id';
|
||||
|
||||
it('should throw PermissionsException when trying to add object permission on system object', async () => {
|
||||
it('should successfully create object permission for system object', async () => {
|
||||
const input: UpsertObjectPermissionsInput = {
|
||||
roleId,
|
||||
objectPermissions: [
|
||||
@@ -108,8 +108,22 @@ describe('ObjectPermissionService', () => {
|
||||
],
|
||||
};
|
||||
|
||||
workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps.mockResolvedValue(
|
||||
{
|
||||
const permissionUniversalId = 'system-permission-universal-id';
|
||||
const freshFlatObjectPermission = {
|
||||
id: 'system-permission-id',
|
||||
universalIdentifier: permissionUniversalId,
|
||||
roleId,
|
||||
roleUniversalIdentifier: roleId,
|
||||
objectMetadataId: systemObjectMetadataId,
|
||||
objectMetadataUniversalIdentifier: systemObjectMetadataId,
|
||||
canReadObjectRecords: true,
|
||||
canUpdateObjectRecords: false,
|
||||
canSoftDeleteObjectRecords: false,
|
||||
canDestroyObjectRecords: false,
|
||||
};
|
||||
|
||||
workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps
|
||||
.mockResolvedValueOnce({
|
||||
flatObjectPermissionMaps: {
|
||||
byUniversalIdentifier: {},
|
||||
universalIdentifierById: {},
|
||||
@@ -120,24 +134,39 @@ describe('ObjectPermissionService', () => {
|
||||
systemObjectMetadataId,
|
||||
true,
|
||||
),
|
||||
} as any,
|
||||
} as any)
|
||||
.mockResolvedValueOnce({
|
||||
flatObjectPermissionMaps: {
|
||||
byUniversalIdentifier: {
|
||||
[permissionUniversalId]: freshFlatObjectPermission,
|
||||
},
|
||||
universalIdentifierById: {
|
||||
[freshFlatObjectPermission.id]: permissionUniversalId,
|
||||
},
|
||||
byId: {
|
||||
[freshFlatObjectPermission.id]: freshFlatObjectPermission,
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
|
||||
workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration.mockResolvedValue(
|
||||
{ status: 'success' } as any,
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.upsertObjectPermissions({
|
||||
workspaceId,
|
||||
input,
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
new PermissionsException(
|
||||
PermissionsExceptionMessage.CANNOT_ADD_OBJECT_PERMISSION_ON_SYSTEM_OBJECT,
|
||||
PermissionsExceptionCode.CANNOT_ADD_OBJECT_PERMISSION_ON_SYSTEM_OBJECT,
|
||||
),
|
||||
);
|
||||
const result = await service.upsertObjectPermissions({
|
||||
workspaceId,
|
||||
input,
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
objectMetadataId: systemObjectMetadataId,
|
||||
canReadObjectRecords: true,
|
||||
canUpdateObjectRecords: false,
|
||||
});
|
||||
expect(
|
||||
workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration,
|
||||
).not.toHaveBeenCalled();
|
||||
).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should successfully create object permission for custom (non-system) object', async () => {
|
||||
|
||||
-10
@@ -100,16 +100,6 @@ export class ObjectPermissionService {
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (objectMetadata.isSystem === true) {
|
||||
throw new PermissionsException(
|
||||
PermissionsExceptionMessage.CANNOT_ADD_OBJECT_PERMISSION_ON_SYSTEM_OBJECT,
|
||||
PermissionsExceptionCode.CANNOT_ADD_OBJECT_PERMISSION_ON_SYSTEM_OBJECT,
|
||||
{
|
||||
userFriendlyMessage: msg`You cannot set permissions on system objects as they are managed by the platform.`,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const flatEntityToCreate: (UniversalFlatObjectPermission & {
|
||||
|
||||
+3
-10
@@ -28,6 +28,7 @@ import { type UserWorkspacePermissions } from 'src/engine/metadata-modules/permi
|
||||
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
|
||||
import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service';
|
||||
import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config';
|
||||
import { getRoleIdsFromRolePermissionConfig } from 'src/engine/twenty-orm/utils/get-role-ids-from-role-permission-config.util';
|
||||
import { InjectWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/inject-workspace-scoped-repository.decorator';
|
||||
import { WorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-repository';
|
||||
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
|
||||
@@ -285,16 +286,8 @@ export class PermissionsService {
|
||||
return null;
|
||||
}
|
||||
|
||||
let roleIds: string[] = [];
|
||||
let useIntersection = false;
|
||||
|
||||
if ('intersectionOf' in rolePermissionConfig) {
|
||||
roleIds = rolePermissionConfig.intersectionOf;
|
||||
useIntersection = true;
|
||||
} else if ('unionOf' in rolePermissionConfig) {
|
||||
roleIds = rolePermissionConfig.unionOf;
|
||||
useIntersection = false;
|
||||
}
|
||||
const roleIds = getRoleIdsFromRolePermissionConfig(rolePermissionConfig);
|
||||
const useIntersection = 'intersectionOf' in rolePermissionConfig;
|
||||
|
||||
if (roleIds.length === 0) {
|
||||
throw new Error('No role IDs provided');
|
||||
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config';
|
||||
|
||||
export const getRoleIdsFromRolePermissionConfig = (
|
||||
rolePermissionConfig: RolePermissionConfig,
|
||||
): string[] => {
|
||||
if ('intersectionOf' in rolePermissionConfig) {
|
||||
return rolePermissionConfig.intersectionOf;
|
||||
}
|
||||
|
||||
if ('unionOf' in rolePermissionConfig) {
|
||||
return rolePermissionConfig.unionOf;
|
||||
}
|
||||
|
||||
return [];
|
||||
};
|
||||
-12
@@ -1,17 +1,5 @@
|
||||
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
|
||||
|
||||
exports[`Object permission upsert should fail when object is system object 1`] = `
|
||||
{
|
||||
"extensions": {
|
||||
"code": "FORBIDDEN",
|
||||
"subCode": "CANNOT_ADD_OBJECT_PERMISSION_ON_SYSTEM_OBJECT",
|
||||
"userFriendlyMessage": "You cannot set permissions on system objects as they are managed by the platform.",
|
||||
},
|
||||
"message": "Cannot add object permission on system object",
|
||||
"name": "ForbiddenError",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Object permission upsert should fail when objectMetadataId does not exist 1`] = `
|
||||
{
|
||||
"extensions": {
|
||||
|
||||
-27
@@ -18,7 +18,6 @@ import { type UpsertObjectPermissionsInput } from 'src/engine/metadata-modules/o
|
||||
type GlobalTestContext = {
|
||||
editableRoleId: string;
|
||||
nonEditableRoleId: string;
|
||||
systemObjectMetadataId: string;
|
||||
nonSystemObjectMetadataId: string;
|
||||
editableRoleWithNoReadId: string;
|
||||
};
|
||||
@@ -97,23 +96,6 @@ const failingObjectPermissionUpsertTestCases: EachTestingContext<TestContext>[]
|
||||
}),
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'when object is system object',
|
||||
context: {
|
||||
input: (globalContext) => ({
|
||||
roleId: globalContext.editableRoleId,
|
||||
objectPermissions: [
|
||||
{
|
||||
objectMetadataId: globalContext.systemObjectMetadataId,
|
||||
canReadObjectRecords: true,
|
||||
canUpdateObjectRecords: false,
|
||||
canSoftDeleteObjectRecords: false,
|
||||
canDestroyObjectRecords: false,
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
},
|
||||
{
|
||||
title:
|
||||
'when read=false but canUpdateObjectRecords=true (read/write consistency)',
|
||||
@@ -137,7 +119,6 @@ const failingObjectPermissionUpsertTestCases: EachTestingContext<TestContext>[]
|
||||
describe('Object permission upsert should fail', () => {
|
||||
let editableRoleId: string;
|
||||
let nonEditableRoleId: string;
|
||||
let systemObjectMetadataId: string;
|
||||
let nonSystemObjectMetadataId: string;
|
||||
let editableRoleWithNoReadId: string;
|
||||
|
||||
@@ -221,13 +202,6 @@ describe('Object permission upsert should fail', () => {
|
||||
getObjectMetadataOperation,
|
||||
);
|
||||
const edges = objectMetadataResponse.body.data?.objects?.edges ?? [];
|
||||
const systemObjectNode = edges.find(
|
||||
(edge: { node: { isSystem: boolean | string } }) =>
|
||||
edge.node.isSystem === true || String(edge.node.isSystem) === 'true',
|
||||
)?.node;
|
||||
jestExpectToBeDefined(systemObjectNode);
|
||||
systemObjectMetadataId = systemObjectNode.id;
|
||||
|
||||
const nonSystemObjectNode = edges.find(
|
||||
(edge: { node: { isSystem: boolean | string } }) =>
|
||||
edge.node.isSystem === false || String(edge.node.isSystem) === 'false',
|
||||
@@ -257,7 +231,6 @@ describe('Object permission upsert should fail', () => {
|
||||
const globalContext: GlobalTestContext = {
|
||||
editableRoleId: editableRoleId ?? '',
|
||||
nonEditableRoleId: nonEditableRoleId ?? '',
|
||||
systemObjectMetadataId: systemObjectMetadataId ?? '',
|
||||
nonSystemObjectMetadataId: nonSystemObjectMetadataId ?? '',
|
||||
editableRoleWithNoReadId: editableRoleWithNoReadId ?? '',
|
||||
};
|
||||
|
||||
+53
@@ -1,14 +1,17 @@
|
||||
import gql from 'graphql-tag';
|
||||
import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util';
|
||||
import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util';
|
||||
import { upsertObjectPermissions } from 'test/integration/metadata/suites/object-permission/utils/upsert-object-permissions.util';
|
||||
import { createOneRole } from 'test/integration/metadata/suites/role/utils/create-one-role.util';
|
||||
import { deleteOneRole } from 'test/integration/metadata/suites/role/utils/delete-one-role.util';
|
||||
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
|
||||
import { jestExpectToBeDefined } from 'test/utils/jest-expect-to-be-defined.util.test';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
describe('Object permission upsert should succeed', () => {
|
||||
let createdRoleId: string;
|
||||
let customObjectMetadataId: string;
|
||||
let systemObjectMetadataId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const { data: roleData } = await createOneRole({
|
||||
@@ -46,6 +49,29 @@ describe('Object permission upsert should succeed', () => {
|
||||
|
||||
customObjectMetadataId = createOneObject.id;
|
||||
jestExpectToBeDefined(customObjectMetadataId);
|
||||
|
||||
const objectMetadataResponse = await makeMetadataAPIRequest({
|
||||
query: gql`
|
||||
query {
|
||||
objects(paging: { first: 1000 }) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
isSystem
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
});
|
||||
const edges = objectMetadataResponse.body.data?.objects?.edges ?? [];
|
||||
const systemObjectNode = edges.find(
|
||||
(edge: { node: { isSystem: boolean | string } }) =>
|
||||
edge.node.isSystem === true || String(edge.node.isSystem) === 'true',
|
||||
)?.node;
|
||||
|
||||
jestExpectToBeDefined(systemObjectNode);
|
||||
systemObjectMetadataId = systemObjectNode.id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -117,6 +143,33 @@ describe('Object permission upsert should succeed', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should upsert object permission on a system object', async () => {
|
||||
const { data } = await upsertObjectPermissions({
|
||||
expectToFail: false,
|
||||
input: {
|
||||
roleId: createdRoleId,
|
||||
objectPermissions: [
|
||||
{
|
||||
objectMetadataId: systemObjectMetadataId,
|
||||
canReadObjectRecords: true,
|
||||
canUpdateObjectRecords: false,
|
||||
canSoftDeleteObjectRecords: false,
|
||||
canDestroyObjectRecords: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(data?.upsertObjectPermissions).toHaveLength(1);
|
||||
expect(data?.upsertObjectPermissions?.[0]).toMatchObject({
|
||||
objectMetadataId: systemObjectMetadataId,
|
||||
canReadObjectRecords: true,
|
||||
canUpdateObjectRecords: false,
|
||||
canSoftDeleteObjectRecords: false,
|
||||
canDestroyObjectRecords: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should upsert with read and all write permissions true', async () => {
|
||||
const { data } = await upsertObjectPermissions({
|
||||
expectToFail: false,
|
||||
|
||||
Reference in New Issue
Block a user