Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 66 additions & 1 deletion apps/sim/app/api/chat/manage/[id]/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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({
Expand Down
18 changes: 18 additions & 0 deletions apps/sim/app/api/chat/manage/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
48 changes: 47 additions & 1 deletion apps/sim/app/api/chat/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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', () =>
Expand All @@ -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(() => {
Expand Down Expand Up @@ -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' },
Expand Down
15 changes: 15 additions & 0 deletions apps/sim/app/api/chat/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')

Expand Down Expand Up @@ -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
}
}
Comment thread
waleedlatif1 marked this conversation as resolved.

const result = await performChatDeploy({
workflowId,
userId: session.user.id,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -371,6 +372,7 @@ export function ChatDeploy({
<AuthSelector
key={`${existingChat?.id ?? 'new'}-${formInitCounter}`}
authType={formData.authType}
savedAuthType={existingChat?.authType as AuthType | undefined}
password={formData.password}
emails={formData.emails}
onAuthTypeChange={(type) => updateField('authType', type)}
Expand Down Expand Up @@ -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
Expand All @@ -602,6 +606,7 @@ const AUTH_LABELS: Record<AuthType, string> = {

function AuthSelector({
authType,
savedAuthType,
password,
emails,
onAuthTypeChange,
Expand Down Expand Up @@ -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
)
Comment thread
waleedlatif1 marked this conversation as resolved.

useEffect(() => {
if (authOptions.length > 0 && !authOptions.includes(authType)) {
onAuthTypeChange(authOptions[0])
}
}, [authOptions, authType, onAuthTypeChange])
Comment thread
waleedlatif1 marked this conversation as resolved.
Comment thread
waleedlatif1 marked this conversation as resolved.

return (
<div className='space-y-4'>
Expand Down
Loading
Loading