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:
Etienne
2026-07-28 15:24:33 +02:00
committed by GitHub
parent dfea3af778
commit 902bc6db63
17 changed files with 357 additions and 104 deletions
@@ -33,7 +33,7 @@ export const WorkflowAiAgentPermissionList = ({
onDeletePermission,
searchQuery,
}: WorkflowAiAgentPermissionListProps) => {
const { alphaSortedActiveNonSystemObjectMetadataItems: objectMetadataItems } =
const { activeObjectMetadataItems: objectMetadataItems } =
useFilteredObjectMetadataItems();
const sortedExistingPermissions = objectPermissions
@@ -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>
);
};
@@ -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 && (
<>
@@ -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,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) => {
@@ -0,0 +1,7 @@
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
export const workflowAiAgentPermissionsIsSystemObjectsListOpenState =
createAtomState<boolean>({
key: 'workflowAiAgentPermissionsIsSystemObjectsListOpenState',
defaultValue: false,
});