Skip to content

Commit 7c55574

Browse files
committed
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]
1 parent d35bc77 commit 7c55574

10 files changed

Lines changed: 266 additions & 22 deletions

File tree

apps/sim/app/api/chat/manage/[id]/route.test.ts

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,9 @@ import {
2020
import { NextRequest } from 'next/server'
2121
import { beforeEach, describe, expect, it, vi } from 'vitest'
2222

23-
const { mockCheckChatAccess } = vi.hoisted(() => ({
23+
const { mockCheckChatAccess, mockValidateChatDeployAuth } = vi.hoisted(() => ({
2424
mockCheckChatAccess: vi.fn(),
25+
mockValidateChatDeployAuth: vi.fn(),
2526
}))
2627

2728
const mockCreateSuccessResponse = workflowsApiUtilsMockFns.mockCreateSuccessResponse
@@ -50,10 +51,20 @@ vi.mock('@/lib/core/utils/urls', () => ({
5051
vi.mock('@/app/api/chat/utils', () => ({
5152
checkChatAccess: mockCheckChatAccess,
5253
}))
54+
vi.mock('@/ee/access-control/utils/permission-check', () => {
55+
class ChatDeployAuthNotAllowedError extends Error {
56+
constructor() {
57+
super('This chat authentication mode is not allowed based on your permission group settings')
58+
this.name = 'ChatDeployAuthNotAllowedError'
59+
}
60+
}
61+
return { validateChatDeployAuth: mockValidateChatDeployAuth, ChatDeployAuthNotAllowedError }
62+
})
5363
vi.mock('@/lib/workflows/persistence/utils', () => workflowsPersistenceUtilsMock)
5464
vi.mock('@/lib/workflows/orchestration', () => workflowsOrchestrationMock)
5565

5666
import { DELETE, GET, PATCH } from '@/app/api/chat/manage/[id]/route'
67+
import { ChatDeployAuthNotAllowedError } from '@/ee/access-control/utils/permission-check'
5768

5869
describe('Chat Edit API Route', () => {
5970
beforeEach(() => {
@@ -213,6 +224,34 @@ describe('Chat Edit API Route', () => {
213224
expect(data.message).toBe('Chat deployment updated successfully')
214225
})
215226

227+
it('returns 403 when the updated auth type is blocked by the permission group', async () => {
228+
authMockFns.mockGetSession.mockResolvedValue({
229+
user: { id: 'user-id' },
230+
})
231+
232+
mockCheckChatAccess.mockResolvedValue({
233+
hasAccess: true,
234+
chat: {
235+
id: 'chat-123',
236+
identifier: 'test-chat',
237+
authType: 'public',
238+
workflowId: 'workflow-123',
239+
},
240+
workspaceId: 'workspace-123',
241+
})
242+
mockValidateChatDeployAuth.mockRejectedValueOnce(new ChatDeployAuthNotAllowedError())
243+
244+
const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', {
245+
method: 'PATCH',
246+
body: JSON.stringify({ authType: 'public' }),
247+
})
248+
const response = await PATCH(req, { params: Promise.resolve({ id: 'chat-123' }) })
249+
250+
expect(response.status).toBe(403)
251+
expect(mockValidateChatDeployAuth).toHaveBeenCalledWith('user-id', 'workspace-123', 'public')
252+
expect(dbChainMockFns.update).not.toHaveBeenCalled()
253+
})
254+
216255
it('rejects the update without admitting a new deploy while an attempt is in flight', async () => {
217256
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-id' } })
218257
mockCheckChatAccess.mockResolvedValue({

apps/sim/app/api/chat/manage/[id]/route.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ import {
2323
createErrorResponse,
2424
createSuccessResponse,
2525
} from '@/app/api/workflows/utils'
26+
import {
27+
ChatDeployAuthNotAllowedError,
28+
validateChatDeployAuth,
29+
} from '@/ee/access-control/utils/permission-check'
2630

2731
export const dynamic = 'force-dynamic'
2832
export const maxDuration = 120
@@ -119,6 +123,17 @@ export const PATCH = withRouteHandler(
119123
return createErrorResponse('Changing the workflow of a chat deployment is not allowed', 400)
120124
}
121125

126+
if (authType && chatWorkspaceId) {
127+
try {
128+
await validateChatDeployAuth(session.user.id, chatWorkspaceId, authType)
129+
} catch (error) {
130+
if (error instanceof ChatDeployAuthNotAllowedError) {
131+
return createErrorResponse(error.message, 403)
132+
}
133+
throw error
134+
}
135+
}
136+
122137
if (identifier && identifier !== existingChat[0].identifier) {
123138
const existingIdentifier = await db
124139
.select()

apps/sim/app/api/chat/route.test.ts

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,9 @@ import {
1616
import { NextRequest } from 'next/server'
1717
import { beforeEach, describe, expect, it, vi } from 'vitest'
1818

19-
const { mockCheckWorkflowAccessForChatCreation } = vi.hoisted(() => ({
19+
const { mockCheckWorkflowAccessForChatCreation, mockValidateChatDeployAuth } = vi.hoisted(() => ({
2020
mockCheckWorkflowAccessForChatCreation: vi.fn(),
21+
mockValidateChatDeployAuth: vi.fn(),
2122
}))
2223

2324
const mockCreateSuccessResponse = workflowsApiUtilsMockFns.mockCreateSuccessResponse
@@ -32,6 +33,16 @@ vi.mock('@/app/api/chat/utils', () => ({
3233
checkWorkflowAccessForChatCreation: mockCheckWorkflowAccessForChatCreation,
3334
}))
3435

36+
vi.mock('@/ee/access-control/utils/permission-check', () => {
37+
class ChatDeployAuthNotAllowedError extends Error {
38+
constructor() {
39+
super('This chat authentication mode is not allowed based on your permission group settings')
40+
this.name = 'ChatDeployAuthNotAllowedError'
41+
}
42+
}
43+
return { validateChatDeployAuth: mockValidateChatDeployAuth, ChatDeployAuthNotAllowedError }
44+
})
45+
3546
vi.mock('@/lib/workflows/orchestration', () => workflowsOrchestrationMock)
3647

3748
vi.mock('@/lib/core/config/env', () =>
@@ -42,6 +53,7 @@ vi.mock('@/lib/core/config/env', () =>
4253
)
4354

4455
import { GET, POST } from '@/app/api/chat/route'
56+
import { ChatDeployAuthNotAllowedError } from '@/ee/access-control/utils/permission-check'
4557

4658
describe('Chat API Route', () => {
4759
beforeEach(() => {
@@ -237,6 +249,40 @@ describe('Chat API Route', () => {
237249
)
238250
})
239251

252+
it('returns 403 when the chat auth type is blocked by the permission group', async () => {
253+
authMockFns.mockGetSession.mockResolvedValue({
254+
user: { id: 'user-id', email: 'user@example.com' },
255+
})
256+
257+
const validData = {
258+
workflowId: 'workflow-123',
259+
identifier: 'test-chat',
260+
title: 'Test Chat',
261+
authType: 'public',
262+
customizations: {
263+
primaryColor: '#000000',
264+
welcomeMessage: 'Hello',
265+
},
266+
}
267+
268+
dbChainMockFns.limit.mockResolvedValueOnce([])
269+
mockCheckWorkflowAccessForChatCreation.mockResolvedValue({
270+
hasAccess: true,
271+
workflow: { userId: 'user-id', workspaceId: 'workspace-1', isDeployed: true },
272+
})
273+
mockValidateChatDeployAuth.mockRejectedValueOnce(new ChatDeployAuthNotAllowedError())
274+
275+
const req = new NextRequest('http://localhost:3000/api/chat', {
276+
method: 'POST',
277+
body: JSON.stringify(validData),
278+
})
279+
const response = await POST(req)
280+
281+
expect(response.status).toBe(403)
282+
expect(mockValidateChatDeployAuth).toHaveBeenCalledWith('user-id', 'workspace-1', 'public')
283+
expect(mockPerformChatDeploy).not.toHaveBeenCalled()
284+
})
285+
240286
it('passes chat customizations and outputConfigs through in the API request shape', async () => {
241287
authMockFns.mockGetSession.mockResolvedValue({
242288
user: { id: 'user-id', email: 'user@example.com' },

apps/sim/app/api/chat/route.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1111
import { performChatDeploy } from '@/lib/workflows/orchestration'
1212
import { checkWorkflowAccessForChatCreation } from '@/app/api/chat/utils'
1313
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
14+
import {
15+
ChatDeployAuthNotAllowedError,
16+
validateChatDeployAuth,
17+
} from '@/ee/access-control/utils/permission-check'
1418

1519
const logger = createLogger('ChatAPI')
1620

@@ -101,6 +105,17 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
101105
return createErrorResponse('Workflow not found or access denied', 404)
102106
}
103107

108+
if (workflowRecord.workspaceId) {
109+
try {
110+
await validateChatDeployAuth(session.user.id, workflowRecord.workspaceId, authType)
111+
} catch (error) {
112+
if (error instanceof ChatDeployAuthNotAllowedError) {
113+
return createErrorResponse(error.message, 403)
114+
}
115+
throw error
116+
}
117+
}
118+
104119
const result = await performChatDeploy({
105120
workflowId,
106121
userId: session.user.id,

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/chat.tsx

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import {
3333
useUpdateChat,
3434
} from '@/hooks/queries/chats'
3535
import type { ChatDetail } from '@/hooks/queries/deployments'
36+
import { usePermissionConfig } from '@/hooks/use-permission-config'
3637
import { useIdentifierValidation } from './hooks'
3738
import {
3839
getPasswordHelperText,
@@ -671,10 +672,20 @@ function AuthSelector({
671672
}
672673
}
673674

675+
const { config: permissionConfig } = usePermissionConfig()
676+
674677
const ssoEnabled = isTruthy(getEnv('NEXT_PUBLIC_SSO_ENABLED'))
675-
const authOptions = ssoEnabled
676-
? (['public', 'password', 'email', 'sso'] as const)
677-
: (['public', 'password', 'email'] as const)
678+
const baseAuthOptions: AuthType[] = ssoEnabled
679+
? ['public', 'password', 'email', 'sso']
680+
: ['public', 'password', 'email']
681+
682+
// Org access-control may restrict which auth modes are allowed (`null` = all).
683+
// The route is the source of truth; this just hides disallowed options, keeping
684+
// the current selection visible so the saved state always shows.
685+
const allowedAuthTypes = permissionConfig.allowedChatDeployAuthTypes
686+
const authOptions = baseAuthOptions.filter(
687+
(type) => allowedAuthTypes === null || allowedAuthTypes.includes(type) || type === authType
688+
)
678689

679690
return (
680691
<div className='space-y-4'>

apps/sim/ee/access-control/components/group-detail.tsx

Lines changed: 50 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,17 @@ const FILE_SHARE_AUTH_TYPE_OPTIONS: { value: ShareAuthType; label: string }[] =
7979
]
8080
const ALL_FILE_SHARE_AUTH_TYPES: ShareAuthType[] = FILE_SHARE_AUTH_TYPE_OPTIONS.map((o) => o.value)
8181

82+
/** Chat-deployment auth modes an admin can allow/disallow. `null` config = all allowed. */
83+
const CHAT_DEPLOY_AUTH_TYPE_OPTIONS: { value: ShareAuthType; label: string }[] = [
84+
{ value: 'public', label: 'Public' },
85+
{ value: 'password', label: 'Password' },
86+
{ value: 'email', label: 'Email' },
87+
{ value: 'sso', label: 'SSO' },
88+
]
89+
const ALL_CHAT_DEPLOY_AUTH_TYPES: ShareAuthType[] = CHAT_DEPLOY_AUTH_TYPE_OPTIONS.map(
90+
(o) => o.value
91+
)
92+
8293
interface OrganizationMemberOption {
8394
userId: string
8495
user: {
@@ -503,7 +514,7 @@ function BlockToolRow({
503514
className='relative flex size-[16px] flex-shrink-0 items-center justify-center overflow-hidden rounded-sm'
504515
style={{ background: block.bgColor }}
505516
>
506-
{BlockIcon && <BlockIcon className='!h-[10px] !w-[10px] text-white' />}
517+
{BlockIcon && <BlockIcon className='!size-[9px] text-white' />}
507518
</div>
508519
<button
509520
type='button'
@@ -737,13 +748,6 @@ export function GroupDetail({
737748
configKey: 'hideDeployChatbot' as const,
738749
hint: 'Hide the chatbot deployment option.',
739750
},
740-
{
741-
id: 'hide-deploy-template',
742-
label: 'Template',
743-
category: 'Deploy Tabs',
744-
configKey: 'hideDeployTemplate' as const,
745-
hint: 'Hide the template publishing option.',
746-
},
747751
{
748752
id: 'disable-mcp',
749753
label: 'MCP Tools',
@@ -1114,6 +1118,19 @@ export function GroupDetail({
11141118
}))
11151119
}, [])
11161120

1121+
const chatDeployAuthValue = useMemo(
1122+
() => editingConfig.allowedChatDeployAuthTypes ?? ALL_CHAT_DEPLOY_AUTH_TYPES,
1123+
[editingConfig.allowedChatDeployAuthTypes]
1124+
)
1125+
1126+
const setChatDeployAuthTypes = useCallback((values: string[]) => {
1127+
setEditingConfig((prev) => ({
1128+
...prev,
1129+
allowedChatDeployAuthTypes:
1130+
values.length === ALL_CHAT_DEPLOY_AUTH_TYPES.length ? null : (values as ShareAuthType[]),
1131+
}))
1132+
}, [])
1133+
11171134
/** Persists the editing buffer. */
11181135
const handleSaveConfig = useCallback(async () => {
11191136
try {
@@ -1495,10 +1512,10 @@ export function GroupDetail({
14951512
onCheckedChange={() => toggleIntegration(block.type)}
14961513
/>
14971514
<div
1498-
className='relative flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center overflow-hidden rounded-sm'
1515+
className='relative flex size-[16px] flex-shrink-0 items-center justify-center overflow-hidden rounded-sm'
14991516
style={{ background: block.bgColor }}
15001517
>
1501-
{BlockIcon && <BlockIcon className='!h-[10px] !w-[10px] text-white' />}
1518+
{BlockIcon && <BlockIcon className='!size-[9px] text-white' />}
15021519
</div>
15031520
<span className='truncate font-medium text-sm'>{block.name}</span>
15041521
</label>
@@ -1594,6 +1611,29 @@ export function GroupDetail({
15941611
</div>
15951612
</SettingsSection>
15961613
))}
1614+
<SettingsSection label='Chat'>
1615+
<div
1616+
className={cn(
1617+
'flex flex-col gap-1.5 px-2 pt-1',
1618+
editingConfig.hideDeployChatbot && 'opacity-50'
1619+
)}
1620+
>
1621+
<span className='text-[var(--text-secondary)] text-xs'>
1622+
Auth modes chat deployments may use
1623+
</span>
1624+
<ChipDropdown
1625+
multiple
1626+
showAllOption={false}
1627+
allLabel='None'
1628+
value={chatDeployAuthValue}
1629+
onChange={setChatDeployAuthTypes}
1630+
options={CHAT_DEPLOY_AUTH_TYPE_OPTIONS}
1631+
disabled={editingConfig.hideDeployChatbot}
1632+
matchTriggerWidth={false}
1633+
className='w-[200px]'
1634+
/>
1635+
</div>
1636+
</SettingsSection>
15971637
<SettingsSection label='Files'>
15981638
<div className='flex flex-col gap-1.5'>
15991639
<label

0 commit comments

Comments
 (0)