diff --git a/apps/sim/app/api/chat/manage/[id]/route.test.ts b/apps/sim/app/api/chat/manage/[id]/route.test.ts index 399caeb3b66..bc765862b4c 100644 --- a/apps/sim/app/api/chat/manage/[id]/route.test.ts +++ b/apps/sim/app/api/chat/manage/[id]/route.test.ts @@ -20,8 +20,9 @@ import { import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockCheckChatAccess } = vi.hoisted(() => ({ +const { mockCheckChatAccess, mockValidateChatDeployAuth } = vi.hoisted(() => ({ mockCheckChatAccess: vi.fn(), + mockValidateChatDeployAuth: vi.fn(), })) const mockCreateSuccessResponse = workflowsApiUtilsMockFns.mockCreateSuccessResponse @@ -50,10 +51,20 @@ vi.mock('@/lib/core/utils/urls', () => ({ vi.mock('@/app/api/chat/utils', () => ({ checkChatAccess: mockCheckChatAccess, })) +vi.mock('@/ee/access-control/utils/permission-check', () => { + class ChatDeployAuthNotAllowedError extends Error { + constructor() { + super('This chat authentication mode is not allowed based on your permission group settings') + this.name = 'ChatDeployAuthNotAllowedError' + } + } + return { validateChatDeployAuth: mockValidateChatDeployAuth, ChatDeployAuthNotAllowedError } +}) vi.mock('@/lib/workflows/persistence/utils', () => workflowsPersistenceUtilsMock) vi.mock('@/lib/workflows/orchestration', () => workflowsOrchestrationMock) import { DELETE, GET, PATCH } from '@/app/api/chat/manage/[id]/route' +import { ChatDeployAuthNotAllowedError } from '@/ee/access-control/utils/permission-check' describe('Chat Edit API Route', () => { beforeEach(() => { @@ -213,6 +224,60 @@ describe('Chat Edit API Route', () => { expect(data.message).toBe('Chat deployment updated successfully') }) + it('returns 403 when the updated auth type changes to a blocked mode', async () => { + authMockFns.mockGetSession.mockResolvedValue({ + user: { id: 'user-id' }, + }) + + mockCheckChatAccess.mockResolvedValue({ + hasAccess: true, + chat: { + id: 'chat-123', + identifier: 'test-chat', + authType: 'password', + workflowId: 'workflow-123', + }, + workspaceId: 'workspace-123', + }) + mockValidateChatDeployAuth.mockRejectedValueOnce(new ChatDeployAuthNotAllowedError()) + + const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', { + method: 'PATCH', + body: JSON.stringify({ authType: 'public' }), + }) + const response = await PATCH(req, { params: Promise.resolve({ id: 'chat-123' }) }) + + expect(response.status).toBe(403) + expect(mockValidateChatDeployAuth).toHaveBeenCalledWith('user-id', 'workspace-123', 'public') + expect(dbChainMockFns.update).not.toHaveBeenCalled() + }) + + it('does not re-check the auth mode when it is unchanged (grandfathered)', async () => { + authMockFns.mockGetSession.mockResolvedValue({ + user: { id: 'user-id' }, + }) + + mockCheckChatAccess.mockResolvedValue({ + hasAccess: true, + chat: { + id: 'chat-123', + identifier: 'test-chat', + authType: 'public', + workflowId: 'workflow-123', + }, + workspaceId: 'workspace-123', + }) + + const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', { + method: 'PATCH', + body: JSON.stringify({ authType: 'public', title: 'Renamed' }), + }) + const response = await PATCH(req, { params: Promise.resolve({ id: 'chat-123' }) }) + + expect(response.status).toBe(200) + expect(mockValidateChatDeployAuth).not.toHaveBeenCalled() + }) + it('rejects the update without admitting a new deploy while an attempt is in flight', async () => { authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-id' } }) mockCheckChatAccess.mockResolvedValue({ diff --git a/apps/sim/app/api/chat/manage/[id]/route.ts b/apps/sim/app/api/chat/manage/[id]/route.ts index 0afb00b152a..08acae6daa0 100644 --- a/apps/sim/app/api/chat/manage/[id]/route.ts +++ b/apps/sim/app/api/chat/manage/[id]/route.ts @@ -23,6 +23,10 @@ import { createErrorResponse, createSuccessResponse, } from '@/app/api/workflows/utils' +import { + ChatDeployAuthNotAllowedError, + validateChatDeployAuth, +} from '@/ee/access-control/utils/permission-check' export const dynamic = 'force-dynamic' export const maxDuration = 120 @@ -119,6 +123,20 @@ export const PATCH = withRouteHandler( return createErrorResponse('Changing the workflow of a chat deployment is not allowed', 400) } + // Enforce the permission group's chat auth-mode allow-list only when the + // mode actually changes, so a grandfathered mode already saved on this chat + // can still be re-saved (e.g. a title-only edit) without a 403. + if (authType && authType !== existingChatRecord.authType && chatWorkspaceId) { + try { + await validateChatDeployAuth(session.user.id, chatWorkspaceId, authType) + } catch (error) { + if (error instanceof ChatDeployAuthNotAllowedError) { + return createErrorResponse(error.message, 403) + } + throw error + } + } + if (identifier && identifier !== existingChat[0].identifier) { const existingIdentifier = await db .select() diff --git a/apps/sim/app/api/chat/route.test.ts b/apps/sim/app/api/chat/route.test.ts index 59d6a72b0e4..d85f62bd2b4 100644 --- a/apps/sim/app/api/chat/route.test.ts +++ b/apps/sim/app/api/chat/route.test.ts @@ -16,8 +16,9 @@ import { import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockCheckWorkflowAccessForChatCreation } = vi.hoisted(() => ({ +const { mockCheckWorkflowAccessForChatCreation, mockValidateChatDeployAuth } = vi.hoisted(() => ({ mockCheckWorkflowAccessForChatCreation: vi.fn(), + mockValidateChatDeployAuth: vi.fn(), })) const mockCreateSuccessResponse = workflowsApiUtilsMockFns.mockCreateSuccessResponse @@ -32,6 +33,16 @@ vi.mock('@/app/api/chat/utils', () => ({ checkWorkflowAccessForChatCreation: mockCheckWorkflowAccessForChatCreation, })) +vi.mock('@/ee/access-control/utils/permission-check', () => { + class ChatDeployAuthNotAllowedError extends Error { + constructor() { + super('This chat authentication mode is not allowed based on your permission group settings') + this.name = 'ChatDeployAuthNotAllowedError' + } + } + return { validateChatDeployAuth: mockValidateChatDeployAuth, ChatDeployAuthNotAllowedError } +}) + vi.mock('@/lib/workflows/orchestration', () => workflowsOrchestrationMock) vi.mock('@/lib/core/config/env', () => @@ -42,6 +53,7 @@ vi.mock('@/lib/core/config/env', () => ) import { GET, POST } from '@/app/api/chat/route' +import { ChatDeployAuthNotAllowedError } from '@/ee/access-control/utils/permission-check' describe('Chat API Route', () => { beforeEach(() => { @@ -237,6 +249,40 @@ describe('Chat API Route', () => { ) }) + it('returns 403 when the chat auth type is blocked by the permission group', async () => { + authMockFns.mockGetSession.mockResolvedValue({ + user: { id: 'user-id', email: 'user@example.com' }, + }) + + const validData = { + workflowId: 'workflow-123', + identifier: 'test-chat', + title: 'Test Chat', + authType: 'public', + customizations: { + primaryColor: '#000000', + welcomeMessage: 'Hello', + }, + } + + dbChainMockFns.limit.mockResolvedValueOnce([]) + mockCheckWorkflowAccessForChatCreation.mockResolvedValue({ + hasAccess: true, + workflow: { userId: 'user-id', workspaceId: 'workspace-1', isDeployed: true }, + }) + mockValidateChatDeployAuth.mockRejectedValueOnce(new ChatDeployAuthNotAllowedError()) + + const req = new NextRequest('http://localhost:3000/api/chat', { + method: 'POST', + body: JSON.stringify(validData), + }) + const response = await POST(req) + + expect(response.status).toBe(403) + expect(mockValidateChatDeployAuth).toHaveBeenCalledWith('user-id', 'workspace-1', 'public') + expect(mockPerformChatDeploy).not.toHaveBeenCalled() + }) + it('passes chat customizations and outputConfigs through in the API request shape', async () => { authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-id', email: 'user@example.com' }, diff --git a/apps/sim/app/api/chat/route.ts b/apps/sim/app/api/chat/route.ts index ca1f8019fe7..03f4e5377af 100644 --- a/apps/sim/app/api/chat/route.ts +++ b/apps/sim/app/api/chat/route.ts @@ -11,6 +11,10 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { performChatDeploy } from '@/lib/workflows/orchestration' import { checkWorkflowAccessForChatCreation } from '@/app/api/chat/utils' import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' +import { + ChatDeployAuthNotAllowedError, + validateChatDeployAuth, +} from '@/ee/access-control/utils/permission-check' const logger = createLogger('ChatAPI') @@ -101,6 +105,17 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return createErrorResponse('Workflow not found or access denied', 404) } + if (workflowRecord.workspaceId) { + try { + await validateChatDeployAuth(session.user.id, workflowRecord.workspaceId, authType) + } catch (error) { + if (error instanceof ChatDeployAuthNotAllowedError) { + return createErrorResponse(error.message, 403) + } + throw error + } + } + const result = await performChatDeploy({ workflowId, userId: session.user.id, diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/chat.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/chat.tsx index 43b1ea42d11..2d2f1d44b14 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/chat.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/chat.tsx @@ -33,6 +33,7 @@ import { useUpdateChat, } from '@/hooks/queries/chats' import type { ChatDetail } from '@/hooks/queries/deployments' +import { usePermissionConfig } from '@/hooks/use-permission-config' import { useIdentifierValidation } from './hooks' import { getPasswordHelperText, @@ -371,6 +372,7 @@ export function ChatDeploy({ updateField('authType', type)} @@ -583,6 +585,8 @@ function IdentifierInput({ interface AuthSelectorProps { authType: AuthType + /** The persisted mode of an existing chat, kept selectable even if newly disallowed. */ + savedAuthType?: AuthType password: string emails: string[] onAuthTypeChange: (type: AuthType) => void @@ -602,6 +606,7 @@ const AUTH_LABELS: Record = { function AuthSelector({ authType, + savedAuthType, password, emails, onAuthTypeChange, @@ -671,10 +676,26 @@ function AuthSelector({ } } - const ssoEnabled = isTruthy(getEnv('NEXT_PUBLIC_SSO_ENABLED')) - const authOptions = ssoEnabled - ? (['public', 'password', 'email', 'sso'] as const) - : (['public', 'password', 'email'] as const) + const { config: permissionConfig } = usePermissionConfig() + const allowedAuthTypes = permissionConfig.allowedChatDeployAuthTypes + + const ssoAvailable = + isTruthy(getEnv('NEXT_PUBLIC_SSO_ENABLED')) || + savedAuthType === 'sso' || + (allowedAuthTypes?.includes('sso') ?? false) + const baseAuthOptions: AuthType[] = ssoAvailable + ? ['public', 'password', 'email', 'sso'] + : ['public', 'password', 'email'] + + const authOptions = baseAuthOptions.filter( + (type) => allowedAuthTypes === null || allowedAuthTypes.includes(type) || type === savedAuthType + ) + + useEffect(() => { + if (authOptions.length > 0 && !authOptions.includes(authType)) { + onAuthTypeChange(authOptions[0]) + } + }, [authOptions, authType, onAuthTypeChange]) return (
diff --git a/apps/sim/ee/access-control/components/group-detail.tsx b/apps/sim/ee/access-control/components/group-detail.tsx index 7bf146ec4ac..47a258f5f9a 100644 --- a/apps/sim/ee/access-control/components/group-detail.tsx +++ b/apps/sim/ee/access-control/components/group-detail.tsx @@ -79,6 +79,17 @@ const FILE_SHARE_AUTH_TYPE_OPTIONS: { value: ShareAuthType; label: string }[] = ] const ALL_FILE_SHARE_AUTH_TYPES: ShareAuthType[] = FILE_SHARE_AUTH_TYPE_OPTIONS.map((o) => o.value) +/** Chat-deployment auth modes an admin can allow/disallow. `null` config = all allowed. */ +const CHAT_DEPLOY_AUTH_TYPE_OPTIONS: { value: ShareAuthType; label: string }[] = [ + { value: 'public', label: 'Public' }, + { value: 'password', label: 'Password' }, + { value: 'email', label: 'Email' }, + { value: 'sso', label: 'SSO' }, +] +const ALL_CHAT_DEPLOY_AUTH_TYPES: ShareAuthType[] = CHAT_DEPLOY_AUTH_TYPE_OPTIONS.map( + (o) => o.value +) + interface OrganizationMemberOption { userId: string user: { @@ -503,7 +514,7 @@ function BlockToolRow({ className='relative flex size-[16px] flex-shrink-0 items-center justify-center overflow-hidden rounded-sm' style={{ background: block.bgColor }} > - {BlockIcon && } + {BlockIcon && }