Fix AI permission gating: use Ask AI for chat UI, AI Settings for admin endpoints (#21030)

## 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
This commit is contained in:
Félix Malfait
2026-05-28 20:21:58 +02:00
committed by GitHub
parent ebfaca5b3d
commit 865ca697ca
6 changed files with 21 additions and 29 deletions
@@ -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,
@@ -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 (
@@ -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;
}
@@ -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,
@@ -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);
@@ -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,