From 865ca697ca69b623b3d798c4255dc6171f6c6a6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Malfait?= Date: Thu, 28 May 2026 20:21:58 +0200 Subject: [PATCH] Fix AI permission gating: use Ask AI for chat UI, AI Settings for admin endpoints (#21030) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Closes #20662. Two AI permission flags exist: - **`AI`** (label "Ask AI") — user-facing: chat with AI agents, use AI features - **`AI_SETTINGS`** (label "AI") — admin: create and configure AI agents After auditing every use of these flags I found: ### Frontend — chat UI gated by the admin permission (user-facing bug from the issue) A user granted only `Ask AI` could not see chat tabs, the "new chat" button (desktop & mobile), or the chat content pane; thread initialization was also skipped, leaving the chat in a half-initialized state and producing intermittent `THREAD_NOT_FOUND` errors. Switched these to `AI`: - `MainNavigationDrawerTabsRow.tsx` - `MainNavigationDrawer.tsx` - `MobileNavigationBar.tsx` - `AgentChatThreadInitializationEffect.tsx` ### Backend — admin-only resolvers gated by the user permission (privilege escalation) Two resolvers had a class-level guard of `AI`, letting any user with the user-facing flag reach admin endpoints (skill CRUD, eval runs). Switched the class-level guards to `AI_SETTINGS`: - `SkillResolver` — create/update/delete/activate/deactivate skills - `AgentTurnResolver` — read turns, run/grade evaluations ### Left as-is (already correct) - `AgentResolver` — class-level `AI` for reads (workflow editors and admin pages both need them), mutation-level `AI_SETTINGS` overrides for writes - `AgentChatResolver` & `AgentChatSubscriptionResolver` — already `AI` - `AiGenerateTextController` — already `AI` - Workspace AI config fields in `workspace.service.ts` — already `AI_SETTINGS` ## Test plan - [ ] As a user with `Ask AI` only (no `AI_SETTINGS`): chat tabs, "new chat" button, and chat history pane are visible on desktop + mobile; sending a message works; no `THREAD_NOT_FOUND` errors - [ ] As a user with `AI_SETTINGS` but no `Ask AI`: chat UI is hidden - [ ] As a user with `Ask AI` only: calling `skills` / `createSkill` / `agentTurns` / `runEvaluationInput` via GraphQL returns permission denied - [ ] As an admin (`AI_SETTINGS`): skill settings and agent eval pages still work --- .../AgentChatThreadInitializationEffect.tsx | 22 ++++++------------- .../components/MainNavigationDrawer.tsx | 6 ++--- .../MainNavigationDrawerTabsRow.tsx | 6 ++--- .../components/MobileNavigationBar.tsx | 6 ++--- .../resolvers/agent-turn.resolver.ts | 5 ++++- .../metadata-modules/skill/skill.resolver.ts | 5 ++++- 6 files changed, 21 insertions(+), 29 deletions(-) diff --git a/packages/twenty-front/src/modules/ai/components/AgentChatThreadInitializationEffect.tsx b/packages/twenty-front/src/modules/ai/components/AgentChatThreadInitializationEffect.tsx index ba802de9ec..faec0f4a98 100644 --- a/packages/twenty-front/src/modules/ai/components/AgentChatThreadInitializationEffect.tsx +++ b/packages/twenty-front/src/modules/ai/components/AgentChatThreadInitializationEffect.tsx @@ -31,9 +31,7 @@ import { export const AgentChatThreadInitializationEffect = () => { const client = useApolloClient(); const { replaceDraft, applyChanges } = useUpdateMetadataStoreDraft(); - const hasAiSettingsPermission = useHasPermissionFlag( - PermissionFlagType.AI_SETTINGS, - ); + const hasAiPermission = useHasPermissionFlag(PermissionFlagType.AI); const currentAiChatThread = useAtomStateValue(currentAiChatThreadState); const setCurrentAiChatThread = useSetAtomState(currentAiChatThreadState); @@ -58,7 +56,7 @@ export const AgentChatThreadInitializationEffect = () => { useAtomState(hasInitializedAgentChatThreadsState); useEffect(() => { - if (storeEntry.status !== 'empty' || !hasAiSettingsPermission) { + if (storeEntry.status !== 'empty' || !hasAiPermission) { return; } @@ -75,19 +73,13 @@ export const AgentChatThreadInitializationEffect = () => { replaceDraft('agentChatThreads', result.data.chatThreads); applyChanges(); }); - }, [ - storeEntry.status, - hasAiSettingsPermission, - client, - replaceDraft, - applyChanges, - ]); + }, [storeEntry.status, hasAiPermission, client, replaceDraft, applyChanges]); useEffect(() => { setAgentChatThreadsLoading( - storeEntry.status === 'empty' && hasAiSettingsPermission, + storeEntry.status === 'empty' && hasAiPermission, ); - }, [storeEntry.status, hasAiSettingsPermission, setAgentChatThreadsLoading]); + }, [storeEntry.status, hasAiPermission, setAgentChatThreadsLoading]); useEffect(() => { if ( @@ -97,7 +89,7 @@ export const AgentChatThreadInitializationEffect = () => { return; } - if (storeEntry.status === 'empty' && hasAiSettingsPermission) { + if (storeEntry.status === 'empty' && hasAiPermission) { return; } @@ -152,7 +144,7 @@ export const AgentChatThreadInitializationEffect = () => { }, [ agentChatVisibleThreads, currentAiChatThread, - hasAiSettingsPermission, + hasAiPermission, hasInitializedAgentChatThreads, setHasInitializedAgentChatThreads, storeEntry.status, diff --git a/packages/twenty-front/src/modules/navigation/components/MainNavigationDrawer.tsx b/packages/twenty-front/src/modules/navigation/components/MainNavigationDrawer.tsx index a24046f696..cdaa5077fc 100644 --- a/packages/twenty-front/src/modules/navigation/components/MainNavigationDrawer.tsx +++ b/packages/twenty-front/src/modules/navigation/components/MainNavigationDrawer.tsx @@ -16,12 +16,10 @@ export const MainNavigationDrawer = ({ className }: { className?: string }) => { navigationDrawerActiveTabState, ); const currentWorkspace = useAtomStateValue(currentWorkspaceState); - const hasAiSettingsPermission = useHasPermissionFlag( - PermissionFlagType.AI_SETTINGS, - ); + const hasAiPermission = useHasPermissionFlag(PermissionFlagType.AI); const showAiChatContent = - hasAiSettingsPermission && + hasAiPermission && navigationDrawerActiveTab === NAVIGATION_DRAWER_TABS.AI_CHAT_HISTORY; return ( diff --git a/packages/twenty-front/src/modules/navigation/components/MainNavigationDrawerTabsRow.tsx b/packages/twenty-front/src/modules/navigation/components/MainNavigationDrawerTabsRow.tsx index 42303c1459..e1b7e8e929 100644 --- a/packages/twenty-front/src/modules/navigation/components/MainNavigationDrawerTabsRow.tsx +++ b/packages/twenty-front/src/modules/navigation/components/MainNavigationDrawerTabsRow.tsx @@ -144,13 +144,11 @@ export const MainNavigationDrawerTabsRow = () => { const setIsNavigationDrawerExpanded = useSetAtomState( isNavigationDrawerExpandedState, ); - const hasAiSettingsPermission = useHasPermissionFlag( - PermissionFlagType.AI_SETTINGS, - ); + const hasAiPermission = useHasPermissionFlag(PermissionFlagType.AI); const isExpanded = isNavigationDrawerExpanded || isMobile; - if (!hasAiSettingsPermission) { + if (!hasAiPermission) { return null; } diff --git a/packages/twenty-front/src/modules/navigation/components/MobileNavigationBar.tsx b/packages/twenty-front/src/modules/navigation/components/MobileNavigationBar.tsx index 6e8e349d3f..45390d5651 100644 --- a/packages/twenty-front/src/modules/navigation/components/MobileNavigationBar.tsx +++ b/packages/twenty-front/src/modules/navigation/components/MobileNavigationBar.tsx @@ -41,9 +41,7 @@ export const MobileNavigationBar = () => { const { switchToNewChat } = useSwitchToNewAiChat(); const { alphaSortedActiveNonSystemObjectMetadataItems } = useFilteredObjectMetadataItems(); - const hasAiSettingsPermission = useHasPermissionFlag( - PermissionFlagType.AI_SETTINGS, - ); + const hasAiPermission = useHasPermissionFlag(PermissionFlagType.AI); const setContextStoreCurrentObjectMetadataItemId = useSetAtomComponentState( contextStoreCurrentObjectMetadataItemIdComponentState, @@ -100,7 +98,7 @@ export const MobileNavigationBar = () => { openRecordsSearchPage(); }, }, - ...(hasAiSettingsPermission + ...(hasAiPermission ? [ { name: 'newAiChat' as const, diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-monitor/resolvers/agent-turn.resolver.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-monitor/resolvers/agent-turn.resolver.ts index f9a4d18d3f..1b76543df6 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-monitor/resolvers/agent-turn.resolver.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-monitor/resolvers/agent-turn.resolver.ts @@ -24,7 +24,10 @@ import { AgentService } from 'src/engine/metadata-modules/ai/ai-agent/agent.serv import { AgentChatThreadEntity } from 'src/engine/metadata-modules/ai/ai-chat/entities/agent-chat-thread.entity'; 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'; -@UseGuards(WorkspaceAuthGuard, SettingsPermissionGuard(PermissionFlagType.AI)) +@UseGuards( + WorkspaceAuthGuard, + SettingsPermissionGuard(PermissionFlagType.AI_SETTINGS), +) @MetadataResolver() export class AgentTurnResolver { private readonly logger = new Logger(AgentTurnResolver.name); diff --git a/packages/twenty-server/src/engine/metadata-modules/skill/skill.resolver.ts b/packages/twenty-server/src/engine/metadata-modules/skill/skill.resolver.ts index dc8117fb61..bd071afa6d 100644 --- a/packages/twenty-server/src/engine/metadata-modules/skill/skill.resolver.ts +++ b/packages/twenty-server/src/engine/metadata-modules/skill/skill.resolver.ts @@ -16,7 +16,10 @@ import { SkillGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules import { SkillService } from 'src/engine/metadata-modules/skill/skill.service'; import { WorkspaceMigrationGraphqlApiExceptionInterceptor } from 'src/engine/workspace-manager/workspace-migration/interceptors/workspace-migration-graphql-api-exception.interceptor'; -@UseGuards(WorkspaceAuthGuard, SettingsPermissionGuard(PermissionFlagType.AI)) +@UseGuards( + WorkspaceAuthGuard, + SettingsPermissionGuard(PermissionFlagType.AI_SETTINGS), +) @UseInterceptors( WorkspaceMigrationGraphqlApiExceptionInterceptor, SkillGraphqlApiExceptionInterceptor,