Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
19c6d34
feat(deploy): let workspace write access deploy workflows
mzxchandra Aug 11, 2026
26a460c
Merge origin/staging into feat/deploy-requires-write
mzxchandra Aug 11, 2026
02509a0
fix(deployments): coerce the numeric branch of the version route param
mzxchandra Aug 11, 2026
e927607
Merge remote-tracking branch 'origin/staging' into feat/deploy-requir…
mzxchandra Aug 11, 2026
c8576c6
fix: pre-landing review fixes
mzxchandra Aug 11, 2026
82a3056
Merge remote-tracking branch 'origin/staging' into feat/deploy-requir…
mzxchandra Aug 11, 2026
55213ad
fix(tools): regenerate tool metadata after the deployment description…
mzxchandra Aug 11, 2026
d24962a
Merge remote-tracking branch 'origin/staging' into feat/deploy-requir…
mzxchandra Aug 11, 2026
02a8ad8
fix(deploy): keep public API exposure admin-only
mzxchandra Aug 11, 2026
2e17646
Merge remote-tracking branch 'origin/staging' into feat/deploy-requir…
mzxchandra Aug 11, 2026
0de5f22
fix(chat): keep public chat deployments admin-only
mzxchandra Aug 11, 2026
7ef7963
Merge remote-tracking branch 'origin/staging' into feat/deploy-requir…
mzxchandra Aug 11, 2026
9223379
fix(chat): close the copilot public-chat bypass, surface the dead end
mzxchandra Aug 12, 2026
a8fd8b1
Merge remote-tracking branch 'origin/staging' into feat/deploy-requir…
mzxchandra Aug 12, 2026
0f9fafc
fix(mcp): keep public workflow MCP servers admin-only
mzxchandra Aug 12, 2026
d15ea3c
Merge remote-tracking branch 'origin/staging' into feat/deploy-requir…
mzxchandra Aug 12, 2026
6ec8f56
Merge remote-tracking branch 'origin/staging' into feat/deploy-requir…
mzxchandra Aug 12, 2026
9b0f176
fix(mcp): gate public exposure on the REST routes, not just the use case
mzxchandra Aug 12, 2026
8ee0429
chore: drop the version-param fix and TODOS.md from this PR
mzxchandra Aug 12, 2026
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
2 changes: 1 addition & 1 deletion apps/sim/app/api/chat/manage/[id]/password/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ const PRIVATE_NO_STORE = { 'Cache-Control': 'private, no-store' } as const

/**
* GET endpoint that reveals a chat deployment's current password.
* Restricted to workspace admins (checkChatAccess requires admin permission
* Restricted to workspace editors (checkChatAccess requires write permission
* on the workflow's workspace); each reveal is recorded in the audit log.
*/
export const GET = withRouteHandler(
Expand Down
25 changes: 18 additions & 7 deletions apps/sim/app/api/chat/manage/[id]/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,17 @@ import {
import { NextRequest } from 'next/server'
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'

const { mockCheckChatAccess, mockCheckNeedsRedeployment, mockValidateChatDeployAuth } = vi.hoisted(
() => ({
mockCheckChatAccess: vi.fn(),
mockCheckNeedsRedeployment: vi.fn(),
mockValidateChatDeployAuth: vi.fn(),
})
)
const {
mockCheckChatAccess,
mockCanExposePublicly,
mockCheckNeedsRedeployment,
mockValidateChatDeployAuth,
} = vi.hoisted(() => ({
mockCheckChatAccess: vi.fn(),
mockCanExposePublicly: vi.fn(),
mockCheckNeedsRedeployment: vi.fn(),
mockValidateChatDeployAuth: vi.fn(),
}))

const mockCreateSuccessResponse = workflowsApiUtilsMockFns.mockCreateSuccessResponse
const mockCreateErrorResponse = workflowsApiUtilsMockFns.mockCreateErrorResponse
Expand All @@ -47,6 +51,10 @@ vi.mock('@/lib/core/security/encryption', () => encryptionMock)
vi.mock('@/app/api/chat/utils', () => ({
checkChatAccess: mockCheckChatAccess,
}))
vi.mock('@/lib/deployments/public-exposure', () => ({
canExposePublicly: mockCanExposePublicly,
}))

vi.mock('@/ee/access-control/utils/permission-check', () => {
class ChatDeployAuthNotAllowedError extends Error {
constructor() {
Expand Down Expand Up @@ -78,6 +86,9 @@ afterAll(() => {
describe('Chat Edit API Route', () => {
beforeEach(() => {
vi.clearAllMocks()
// Existing chat suites deploy with authType public; default to admin so they
// keep testing what they were written to test.
mockCanExposePublicly.mockResolvedValue(true)
resetDbChainMock()
mockPerformChatUndeploy.mockResolvedValue({ success: true })

Expand Down
7 changes: 7 additions & 0 deletions apps/sim/app/api/chat/manage/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { isDev } from '@/lib/core/config/env-flags'
import { encryptSecret } from '@/lib/core/security/encryption'
import { getEmailDomain } from '@/lib/core/utils/urls'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { canExposePublicly } from '@/lib/deployments/public-exposure'
import { checkNeedsRedeployment } from '@/lib/workflows/deployment-status'
import {
getWorkflowDeploymentSummary,
Expand Down Expand Up @@ -127,6 +128,12 @@ export const PATCH = withRouteHandler(
// 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) {
// Only the transition *to* public is admin-gated. Leaving an already-public
// chat as-is, or moving it off public, does not increase exposure.
if (authType === 'public' && !(await canExposePublicly(session.user.id, chatWorkspaceId))) {
return createErrorResponse('Only admins can make a chat public', 403)
}

try {
await validateChatDeployAuth(session.user.id, chatWorkspaceId, authType)
} catch (error) {
Expand Down
73 changes: 72 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,13 @@ import {
import { NextRequest } from 'next/server'
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'

const { mockCheckWorkflowAccessForChatCreation, mockValidateChatDeployAuth } = vi.hoisted(() => ({
const {
mockCheckWorkflowAccessForChatCreation,
mockCanExposePublicly,
mockValidateChatDeployAuth,
} = vi.hoisted(() => ({
mockCheckWorkflowAccessForChatCreation: vi.fn(),
mockCanExposePublicly: vi.fn(),
mockValidateChatDeployAuth: vi.fn(),
}))

Expand All @@ -31,6 +36,10 @@ vi.mock('@/app/api/chat/utils', () => ({
checkWorkflowAccessForChatCreation: mockCheckWorkflowAccessForChatCreation,
}))

vi.mock('@/lib/deployments/public-exposure', () => ({
canExposePublicly: mockCanExposePublicly,
}))

vi.mock('@/ee/access-control/utils/permission-check', () => {
class ChatDeployAuthNotAllowedError extends Error {
constructor() {
Expand All @@ -53,6 +62,9 @@ describe('Chat API Route', () => {

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

mockCreateSuccessResponse.mockImplementation((data) => {
Expand Down Expand Up @@ -251,6 +263,65 @@ describe('Chat API Route', () => {
)
})

it('returns 403 when a non-admin deploys a public chat', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-id', email: 'user@example.com' },
})

dbChainMockFns.limit.mockResolvedValueOnce([])
mockCheckWorkflowAccessForChatCreation.mockResolvedValue({
hasAccess: true,
workflow: { userId: 'user-id', workspaceId: 'workspace-1', isDeployed: true },
})
// Write access is enough to deploy a chat, but not to make one public.
mockCanExposePublicly.mockResolvedValue(false)

const response = await POST(
new NextRequest('http://localhost:3000/api/chat', {
method: 'POST',
body: JSON.stringify({
workflowId: 'workflow-123',
identifier: 'test-chat',
title: 'Test Chat',
authType: 'public',
customizations: { primaryColor: '#000000', welcomeMessage: 'Hello' },
}),
})
)

expect(response.status).toBe(403)
expect(mockCanExposePublicly).toHaveBeenCalledWith('user-id', 'workspace-1')
})

it('lets a non-admin deploy a password-protected chat', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-id', email: 'user@example.com' },
})

dbChainMockFns.limit.mockResolvedValueOnce([])
mockCheckWorkflowAccessForChatCreation.mockResolvedValue({
hasAccess: true,
workflow: { userId: 'user-id', workspaceId: 'workspace-1', isDeployed: true },
})
mockCanExposePublicly.mockResolvedValue(false)

const response = await POST(
new NextRequest('http://localhost:3000/api/chat', {
method: 'POST',
body: JSON.stringify({
workflowId: 'workflow-123',
identifier: 'test-chat',
title: 'Test Chat',
authType: 'password',
password: 'test-password',
customizations: { primaryColor: '#000000', welcomeMessage: 'Hello' },
}),
})
)

expect(response.status).not.toBe(403)
})

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' },
Expand Down
8 changes: 8 additions & 0 deletions apps/sim/app/api/chat/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { createChatContract } from '@/lib/api/contracts/chats'
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { canExposePublicly } from '@/lib/deployments/public-exposure'
import { performChatDeploy } from '@/lib/workflows/orchestration'
import { checkWorkflowAccessForChatCreation } from '@/app/api/chat/utils'
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
Expand Down Expand Up @@ -113,6 +114,13 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
}

if (workflowRecord.workspaceId) {
if (
authType === 'public' &&
!(await canExposePublicly(session.user.id, workflowRecord.workspaceId))
) {
return createErrorResponse('Only admins can deploy a public chat', 403)
}

try {
await validateChatDeployAuth(session.user.id, workflowRecord.workspaceId, authType)
} catch (error) {
Expand Down
4 changes: 2 additions & 2 deletions apps/sim/app/api/chat/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export async function checkWorkflowAccessForChatCreation(
const authorization = await authorizeWorkflowByWorkspacePermission({
workflowId,
userId,
action: 'admin',
action: 'write',
})

if (!authorization.workflow) {
Expand Down Expand Up @@ -71,7 +71,7 @@ export async function checkChatAccess(
const authorization = await authorizeWorkflowByWorkspacePermission({
workflowId: chatRecord.workflowId,
userId,
action: 'admin',
action: 'write',
})

return authorization.allowed
Expand Down
38 changes: 38 additions & 0 deletions apps/sim/app/api/mcp/workflow-servers/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
workflowMcpServerParamsSchema,
} from '@/lib/api/contracts/workflow-mcp-servers'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { canExposePublicly, increasesPublicExposure } from '@/lib/deployments/public-exposure'
import {
mcpBodyReadErrorResponse,
readMcpJsonBodyWithLimit,
Expand Down Expand Up @@ -109,6 +110,43 @@ export const PATCH = withRouteHandler(

logger.info(`[${requestId}] Updating workflow MCP server: ${serverId}`)

/**
* `withMcpAuth('write')` covers managing the server, but a public
* server skips authentication on the serve path, so publishing one is
* admin-only. Only the transition is gated: the edit form resubmits the
* server's current visibility alongside whatever field changed, so a
* `write` member must still be able to rename an already-public server.
*
* This route calls the orchestration layer directly rather than the
* application use case, so `updateWorkflowMcpDeploymentServer`'s gate
* does not apply here and the rule has to be repeated. Delete this copy
* once the route goes through that use case.
*/
if (body.isPublic === true) {
const [current] = await db
.select({ isPublic: workflowMcpServer.isPublic })
.from(workflowMcpServer)
.where(
and(
eq(workflowMcpServer.id, serverId),
eq(workflowMcpServer.workspaceId, workspaceId),
isNull(workflowMcpServer.deletedAt)
)
)
.limit(1)

if (
increasesPublicExposure(body.isPublic, current?.isPublic) &&
!(await canExposePublicly(userId, workspaceId))
) {
return createMcpErrorResponse(
new Error('Only admins can make an MCP server public'),
'Only admins can make an MCP server public',
403
)
}
}

const result = await performUpdateWorkflowMcpServer({
serverId,
workspaceId,
Expand Down
Loading
Loading