From 7c555749c9a5be77e322b0ebd87583994fdde6b0 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 21 Jul 2026 14:08:41 -0700 Subject: [PATCH 1/5] feat(access-control): per-group chat-deploy auth modes + polish - Add a per-permission-group 'Auth modes chat deployments may use' control, mirroring the existing public-file-share auth-mode control, so admins can disable specific chat auth modes (Public, Password, ...) - Enforce it server-side on chat create/update via validateChatDeployAuth (ChatDeployAuthNotAllowedError -> 403), and filter the chat deploy UI's Access-control options to the allowed set (keeping the saved mode visible) - Remove the dead 'Template' deploy option (hideDeployTemplate had no consumer) - Shrink access-control block permission icons (size-[9px]) so they no longer touch their tile borders; normalize the tiles to size-[16px] --- .../app/api/chat/manage/[id]/route.test.ts | 41 ++++++++++++- apps/sim/app/api/chat/manage/[id]/route.ts | 15 +++++ apps/sim/app/api/chat/route.test.ts | 48 ++++++++++++++- apps/sim/app/api/chat/route.ts | 15 +++++ .../deploy-modal/components/chat/chat.tsx | 17 +++++- .../components/group-detail.tsx | 60 +++++++++++++++---- .../utils/permission-check.test.ts | 36 ++++++++++- .../access-control/utils/permission-check.ts | 36 +++++++++++ .../lib/api/contracts/permission-groups.ts | 2 +- apps/sim/lib/permission-groups/types.ts | 18 ++++-- 10 files changed, 266 insertions(+), 22 deletions(-) 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..70a0a75eddc 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,34 @@ describe('Chat Edit API Route', () => { expect(data.message).toBe('Chat deployment updated successfully') }) + it('returns 403 when the updated auth type is blocked by the permission group', 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', + }) + 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('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..6ae8a189410 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,17 @@ export const PATCH = withRouteHandler( return createErrorResponse('Changing the workflow of a chat deployment is not allowed', 400) } + if (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..85c8b2d1d3e 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, @@ -671,10 +672,20 @@ function AuthSelector({ } } + const { config: permissionConfig } = usePermissionConfig() + const ssoEnabled = isTruthy(getEnv('NEXT_PUBLIC_SSO_ENABLED')) - const authOptions = ssoEnabled - ? (['public', 'password', 'email', 'sso'] as const) - : (['public', 'password', 'email'] as const) + const baseAuthOptions: AuthType[] = ssoEnabled + ? ['public', 'password', 'email', 'sso'] + : ['public', 'password', 'email'] + + // Org access-control may restrict which auth modes are allowed (`null` = all). + // The route is the source of truth; this just hides disallowed options, keeping + // the current selection visible so the saved state always shows. + const allowedAuthTypes = permissionConfig.allowedChatDeployAuthTypes + const authOptions = baseAuthOptions.filter( + (type) => allowedAuthTypes === null || allowedAuthTypes.includes(type) || type === authType + ) 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..2fcf1713d19 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 && }