Skip to content

Commit 0de5f22

Browse files
committed
fix(chat): keep public chat deployments admin-only
A chat deployed with authType 'public' is invocable by anyone holding the URL with no authentication — the same unauthenticated exposure as a public workflow API, which is admin-only. Deploying a chat itself stays at write, so this gates the exposure rather than the deployment: an editor ships a password/email/SSO chat, an admin is required to make one public. Only the transition *to* public is gated. Editing an already-public chat, or moving it off public, stays at write — neither increases exposure. The chat auth selector disables Public for non-admins with a tooltip, and the create default (which is 'public') falls back to the first mode they can actually deploy, so an editor does not hit a wall on a fresh chat. Existing chat suites mock @/app/api/chat/utils wholesale and deploy with authType public, so they now default the new gate to admin and keep testing what they were written to test.
1 parent 2e17646 commit 0de5f22

7 files changed

Lines changed: 190 additions & 17 deletions

File tree

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

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,17 @@ import {
2323
import { NextRequest } from 'next/server'
2424
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
2525

26-
const { mockCheckChatAccess, mockCheckNeedsRedeployment, mockValidateChatDeployAuth } = vi.hoisted(
27-
() => ({
28-
mockCheckChatAccess: vi.fn(),
29-
mockCheckNeedsRedeployment: vi.fn(),
30-
mockValidateChatDeployAuth: vi.fn(),
31-
})
32-
)
26+
const {
27+
mockCheckChatAccess,
28+
mockCanSetPublicChatAuth,
29+
mockCheckNeedsRedeployment,
30+
mockValidateChatDeployAuth,
31+
} = vi.hoisted(() => ({
32+
mockCheckChatAccess: vi.fn(),
33+
mockCanSetPublicChatAuth: vi.fn(),
34+
mockCheckNeedsRedeployment: vi.fn(),
35+
mockValidateChatDeployAuth: vi.fn(),
36+
}))
3337

3438
const mockCreateSuccessResponse = workflowsApiUtilsMockFns.mockCreateSuccessResponse
3539
const mockCreateErrorResponse = workflowsApiUtilsMockFns.mockCreateErrorResponse
@@ -46,6 +50,7 @@ vi.mock('@/app/api/workflows/utils', () => workflowsApiUtilsMock)
4650
vi.mock('@/lib/core/security/encryption', () => encryptionMock)
4751
vi.mock('@/app/api/chat/utils', () => ({
4852
checkChatAccess: mockCheckChatAccess,
53+
canSetPublicChatAuth: mockCanSetPublicChatAuth,
4954
}))
5055
vi.mock('@/ee/access-control/utils/permission-check', () => {
5156
class ChatDeployAuthNotAllowedError extends Error {
@@ -78,6 +83,9 @@ afterAll(() => {
7883
describe('Chat Edit API Route', () => {
7984
beforeEach(() => {
8085
vi.clearAllMocks()
86+
// Existing chat suites deploy with authType public; default to admin so they
87+
// keep testing what they were written to test.
88+
mockCanSetPublicChatAuth.mockResolvedValue(true)
8189
resetDbChainMock()
8290
mockPerformChatUndeploy.mockResolvedValue({ success: true })
8391

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

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import {
1818
performChatUndeploy,
1919
performFullDeploy,
2020
} from '@/lib/workflows/orchestration'
21-
import { checkChatAccess } from '@/app/api/chat/utils'
21+
import { canSetPublicChatAuth, checkChatAccess } from '@/app/api/chat/utils'
2222
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
2323
import {
2424
ChatDeployAuthNotAllowedError,
@@ -127,6 +127,15 @@ export const PATCH = withRouteHandler(
127127
// mode actually changes, so a grandfathered mode already saved on this chat
128128
// can still be re-saved (e.g. a title-only edit) without a 403.
129129
if (authType && authType !== existingChatRecord.authType && chatWorkspaceId) {
130+
// Only the transition *to* public is admin-gated. Leaving an already-public
131+
// chat as-is, or moving it off public, does not increase exposure.
132+
if (
133+
authType === 'public' &&
134+
!(await canSetPublicChatAuth(session.user.id, chatWorkspaceId))
135+
) {
136+
return createErrorResponse('Only admins can make a chat public', 403)
137+
}
138+
130139
try {
131140
await validateChatDeployAuth(session.user.id, chatWorkspaceId, authType)
132141
} catch (error) {

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

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

19-
const { mockCheckWorkflowAccessForChatCreation, mockValidateChatDeployAuth } = vi.hoisted(() => ({
19+
const {
20+
mockCheckWorkflowAccessForChatCreation,
21+
mockCanSetPublicChatAuth,
22+
mockValidateChatDeployAuth,
23+
} = vi.hoisted(() => ({
2024
mockCheckWorkflowAccessForChatCreation: vi.fn(),
25+
mockCanSetPublicChatAuth: vi.fn(),
2126
mockValidateChatDeployAuth: vi.fn(),
2227
}))
2328

@@ -29,6 +34,7 @@ vi.mock('@/app/api/workflows/utils', () => workflowsApiUtilsMock)
2934

3035
vi.mock('@/app/api/chat/utils', () => ({
3136
checkWorkflowAccessForChatCreation: mockCheckWorkflowAccessForChatCreation,
37+
canSetPublicChatAuth: mockCanSetPublicChatAuth,
3238
}))
3339

3440
vi.mock('@/ee/access-control/utils/permission-check', () => {
@@ -53,6 +59,9 @@ describe('Chat API Route', () => {
5359

5460
beforeEach(() => {
5561
vi.clearAllMocks()
62+
// Existing chat suites deploy with authType public; default to admin so they
63+
// keep testing what they were written to test.
64+
mockCanSetPublicChatAuth.mockResolvedValue(true)
5665
setEnv({ NODE_ENV: 'development', NEXT_PUBLIC_APP_URL: 'http://localhost:3000' })
5766

5867
mockCreateSuccessResponse.mockImplementation((data) => {
@@ -251,6 +260,65 @@ describe('Chat API Route', () => {
251260
)
252261
})
253262

263+
it('returns 403 when a non-admin deploys a public chat', async () => {
264+
authMockFns.mockGetSession.mockResolvedValue({
265+
user: { id: 'user-id', email: 'user@example.com' },
266+
})
267+
268+
dbChainMockFns.limit.mockResolvedValueOnce([])
269+
mockCheckWorkflowAccessForChatCreation.mockResolvedValue({
270+
hasAccess: true,
271+
workflow: { userId: 'user-id', workspaceId: 'workspace-1', isDeployed: true },
272+
})
273+
// Write access is enough to deploy a chat, but not to make one public.
274+
mockCanSetPublicChatAuth.mockResolvedValue(false)
275+
276+
const response = await POST(
277+
new NextRequest('http://localhost:3000/api/chat', {
278+
method: 'POST',
279+
body: JSON.stringify({
280+
workflowId: 'workflow-123',
281+
identifier: 'test-chat',
282+
title: 'Test Chat',
283+
authType: 'public',
284+
customizations: { primaryColor: '#000000', welcomeMessage: 'Hello' },
285+
}),
286+
})
287+
)
288+
289+
expect(response.status).toBe(403)
290+
expect(mockCanSetPublicChatAuth).toHaveBeenCalledWith('user-id', 'workspace-1')
291+
})
292+
293+
it('lets a non-admin deploy a password-protected chat', async () => {
294+
authMockFns.mockGetSession.mockResolvedValue({
295+
user: { id: 'user-id', email: 'user@example.com' },
296+
})
297+
298+
dbChainMockFns.limit.mockResolvedValueOnce([])
299+
mockCheckWorkflowAccessForChatCreation.mockResolvedValue({
300+
hasAccess: true,
301+
workflow: { userId: 'user-id', workspaceId: 'workspace-1', isDeployed: true },
302+
})
303+
mockCanSetPublicChatAuth.mockResolvedValue(false)
304+
305+
const response = await POST(
306+
new NextRequest('http://localhost:3000/api/chat', {
307+
method: 'POST',
308+
body: JSON.stringify({
309+
workflowId: 'workflow-123',
310+
identifier: 'test-chat',
311+
title: 'Test Chat',
312+
authType: 'password',
313+
password: 'test-password',
314+
customizations: { primaryColor: '#000000', welcomeMessage: 'Hello' },
315+
}),
316+
})
317+
)
318+
319+
expect(response.status).not.toBe(403)
320+
})
321+
254322
it('returns 403 when the chat auth type is blocked by the permission group', async () => {
255323
authMockFns.mockGetSession.mockResolvedValue({
256324
user: { id: 'user-id', email: 'user@example.com' },

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

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
99
import { getSession } from '@/lib/auth'
1010
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1111
import { performChatDeploy } from '@/lib/workflows/orchestration'
12-
import { checkWorkflowAccessForChatCreation } from '@/app/api/chat/utils'
12+
import { canSetPublicChatAuth, checkWorkflowAccessForChatCreation } from '@/app/api/chat/utils'
1313
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
1414
import {
1515
ChatDeployAuthNotAllowedError,
@@ -113,6 +113,13 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
113113
}
114114

115115
if (workflowRecord.workspaceId) {
116+
if (
117+
authType === 'public' &&
118+
!(await canSetPublicChatAuth(session.user.id, workflowRecord.workspaceId))
119+
) {
120+
return createErrorResponse('Only admins can deploy a public chat', 403)
121+
}
122+
116123
try {
117124
await validateChatDeployAuth(session.user.id, workflowRecord.workspaceId, authType)
118125
} catch (error) {

apps/sim/app/api/chat/utils.permissions.test.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,17 @@
33
*/
44

55
import { workflowAuthzMockFns } from '@sim/testing'
6+
7+
const { mockGetUserEntityPermissions } = vi.hoisted(() => ({
8+
mockGetUserEntityPermissions: vi.fn(),
9+
}))
10+
11+
vi.mock('@/lib/workspaces/permissions/utils', () => ({
12+
getUserEntityPermissions: mockGetUserEntityPermissions,
13+
}))
14+
615
import { beforeEach, describe, expect, it, vi } from 'vitest'
7-
import { checkWorkflowAccessForChatCreation } from '@/app/api/chat/utils'
16+
import { canSetPublicChatAuth, checkWorkflowAccessForChatCreation } from '@/app/api/chat/utils'
817

918
/**
1019
* Chat deployment dropped from `admin` to `write` alongside the rest of the
@@ -67,3 +76,30 @@ describe('chat deployment permission level', () => {
6776
expect(result.hasAccess).toBe(false)
6877
})
6978
})
79+
80+
/**
81+
* A chat deployed with `authType: 'public'` is invocable by anyone with the URL
82+
* and no authentication — the same exposure as a public workflow API, which is
83+
* admin-only. Deploying an authenticated chat stays at `write`.
84+
*/
85+
describe('public chat auth is admin-only', () => {
86+
beforeEach(() => {
87+
vi.clearAllMocks()
88+
})
89+
90+
it('allows an admin', async () => {
91+
mockGetUserEntityPermissions.mockResolvedValue('admin')
92+
await expect(canSetPublicChatAuth('user-1', 'ws-1')).resolves.toBe(true)
93+
expect(mockGetUserEntityPermissions).toHaveBeenCalledWith('user-1', 'workspace', 'ws-1')
94+
})
95+
96+
it.each(['write', 'read'] as const)('refuses a %s member', async (permission) => {
97+
mockGetUserEntityPermissions.mockResolvedValue(permission)
98+
await expect(canSetPublicChatAuth('user-1', 'ws-1')).resolves.toBe(false)
99+
})
100+
101+
it('refuses a member with no permission on the workspace', async () => {
102+
mockGetUserEntityPermissions.mockResolvedValue(null)
103+
await expect(canSetPublicChatAuth('user-1', 'ws-1')).resolves.toBe(false)
104+
})
105+
})

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,22 @@ import {
88
type DeploymentAuthResult,
99
validateDeploymentAuth,
1010
} from '@/lib/core/security/deployment-auth'
11+
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
12+
13+
/**
14+
* A chat deployed with `authType: 'public'` is invocable by anyone holding the
15+
* URL, with no authentication — the same unauthenticated exposure as a public
16+
* workflow API, which is admin-only. Deploying a chat itself only needs
17+
* `write`, so this gates the exposure rather than the deployment: an editor can
18+
* ship a password/email/SSO chat, but only an admin can make one public.
19+
*
20+
* Only the *transition to* public is gated. Editing an already-public chat, or
21+
* moving it off public, stays at `write` — neither increases exposure.
22+
*/
23+
export async function canSetPublicChatAuth(userId: string, workspaceId: string): Promise<boolean> {
24+
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
25+
return permission === 'admin'
26+
}
1127

1228
export function setChatAuthCookie(
1329
response: NextResponse,

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

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { GeneratedPasswordInput } from '@/components/ui'
2323
import { isSsoEnabled } from '@/lib/core/config/env-flags'
2424
import { getBaseUrl, getEmailDomain } from '@/lib/core/utils/urls'
2525
import { validateAllowlistEntry } from '@/lib/messaging/email/validation'
26+
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
2627
import { OutputSelect } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select'
2728
import {
2829
type AuthType,
@@ -706,6 +707,14 @@ function AuthSelector({
706707

707708
const { config: permissionConfig } = usePermissionConfig()
708709
const allowedAuthTypes = permissionConfig.allowedChatDeployAuthTypes
710+
/**
711+
* A public chat is invocable by anyone holding the URL with no auth, so it is
712+
* admin-only — the same boundary the workflow's public API sits behind.
713+
* Deploying an authenticated chat stays at `write`. An already-public chat
714+
* keeps the option selectable so an editor can still edit its other fields.
715+
*/
716+
const { canAdmin } = useUserPermissionsContext()
717+
const canSetPublic = canAdmin || savedAuthType === 'public'
709718

710719
const ssoAvailable =
711720
isSsoEnabled || savedAuthType === 'sso' || (allowedAuthTypes?.includes('sso') ?? false)
@@ -720,8 +729,15 @@ function AuthSelector({
720729
useEffect(() => {
721730
if (authOptions.length > 0 && !authOptions.includes(authType)) {
722731
onAuthTypeChange(authOptions[0])
732+
return
723733
}
724-
}, [authOptions, authType, onAuthTypeChange])
734+
// A non-admin defaults to 'public' on a fresh chat, which they cannot use —
735+
// move them to the first mode they can actually deploy.
736+
if (authType === 'public' && !canSetPublic) {
737+
const fallback = authOptions.find((type) => type !== 'public')
738+
if (fallback) onAuthTypeChange(fallback)
739+
}
740+
}, [authOptions, authType, onAuthTypeChange, canSetPublic])
725741

726742
return (
727743
<div className='space-y-4'>
@@ -734,11 +750,24 @@ function AuthSelector({
734750
onValueChange={(val) => onAuthTypeChange(val as AuthType)}
735751
disabled={disabled}
736752
>
737-
{authOptions.map((type) => (
738-
<ButtonGroupItem key={type} value={type}>
739-
{AUTH_LABELS[type]}
740-
</ButtonGroupItem>
741-
))}
753+
{authOptions.map((type) =>
754+
type === 'public' && !canSetPublic ? (
755+
<Tooltip.Root key={type}>
756+
<Tooltip.Trigger asChild>
757+
<span className='inline-flex'>
758+
<ButtonGroupItem value={type} disabled>
759+
{AUTH_LABELS[type]}
760+
</ButtonGroupItem>
761+
</span>
762+
</Tooltip.Trigger>
763+
<Tooltip.Content>Only admins can deploy a public chat</Tooltip.Content>
764+
</Tooltip.Root>
765+
) : (
766+
<ButtonGroupItem key={type} value={type}>
767+
{AUTH_LABELS[type]}
768+
</ButtonGroupItem>
769+
)
770+
)}
742771
</ButtonGroup>
743772
</div>
744773

0 commit comments

Comments
 (0)