Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
20 changes: 20 additions & 0 deletions packages/server/src/controllers/chatflows/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,25 @@ const getChatflowById = async (req: Request, res: Response, next: NextFunction)
}
}

// Resolve the owning workspace of a flow for the requesting user IFF they are a member of it (else an
// identical 404 — no cross-workspace disclosure). Used by the UI to auto-switch the active workspace when a
// user opens a flow URL that lives in a workspace they belong to but isn't active. UI-only: API-key callers
// have no req.user.id, so the service treats them as non-members (404).
const getChatflowWorkspace = async (req: Request, res: Response, next: NextFunction) => {
try {
if (typeof req.params === 'undefined' || !req.params.id) {
throw new InternalFlowiseError(
StatusCodes.PRECONDITION_FAILED,
`Error: chatflowsController.getChatflowWorkspace - id not provided!`
)
}
const apiResponse = await chatflowsService.resolveChatflowWorkspace(req.params.id, req.user?.id)
return res.json(apiResponse)
} catch (error) {
next(error)
}
}

const saveChatflow = async (req: Request, res: Response, next: NextFunction) => {
try {
if (!req.body) {
Expand Down Expand Up @@ -444,6 +463,7 @@ export default {
getAllChatflows,
getChatflowByApiKey,
getChatflowById,
getChatflowWorkspace,
saveChatflow,
updateChatflow,
getSinglePublicChatflow,
Expand Down
8 changes: 8 additions & 0 deletions packages/server/src/routes/chatflows/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,14 @@ router.get(
checkAnyPermission('chatflows:view,chatflows:update,chatflows:delete,agentflows:view,agentflows:update,agentflows:delete'),
chatflowsController.getChatflowById
)
// Resolve the owning workspace of a flow for the current user (membership-gated; identical 404 otherwise).
// Lets the UI auto-switch the active workspace when opening a flow URL from another (member) workspace.
// Two-segment path so it never collides with the single-segment '/:id' matcher above.
router.get(
'/resolve-workspace/:id',
checkAnyPermission('chatflows:view,chatflows:update,chatflows:delete,agentflows:view,agentflows:update,agentflows:delete'),
chatflowsController.getChatflowWorkspace
)
router.get(['/apikey/', '/apikey/:apikey'], chatflowsController.getChatflowByApiKey)

// UPDATE
Expand Down
46 changes: 46 additions & 0 deletions packages/server/src/services/chatflows/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { ChatMessageFeedback } from '../../database/entities/ChatMessageFeedback
import { ScheduleTriggerType } from '../../database/entities/ScheduleRecord'
import { UpsertHistory } from '../../database/entities/UpsertHistory'
import { Workspace } from '../../enterprise/database/entities/workspace.entity'
import { WorkspaceUser } from '../../enterprise/database/entities/workspace-user.entity'
Comment thread
dkindlund marked this conversation as resolved.
Outdated
import { getWorkspaceSearchOptions } from '../../enterprise/utils/ControllerServiceUtils'
import { InternalFlowiseError } from '../../errors/internalFlowiseError'
import { getErrorMessage } from '../../errors/utils'
Expand Down Expand Up @@ -304,6 +305,50 @@ const getChatflowById = async (chatflowId: string, workspaceId?: string): Promis
}
}

// Resolve which workspace owns a flow, but ONLY for a caller who is a member of that workspace. A chatflow
// id is a globally-unique UUID, so it maps to exactly one owning workspace; the UI uses this to auto-switch
// the active workspace when a user opens a flow URL that lives in a workspace they belong to but isn't
// currently active. SECURITY: every failure mode returns the IDENTICAL 404 as getChatflowById, so this never
// discloses that a flow exists in a workspace the caller cannot access. API-key callers (no userId) -> 404.
const resolveChatflowWorkspace = async (chatflowId: string, userId?: string): Promise<{ workspaceId: string }> => {
const notFound = () => new InternalFlowiseError(StatusCodes.NOT_FOUND, `Chatflow ${chatflowId} not found in the database!`)
try {
if (!isValidUUID(chatflowId)) {
throw new InternalFlowiseError(StatusCodes.BAD_REQUEST, ChatflowErrorMessage.INVALID_CHATFLOW_ID)
}
if (!userId) {
throw notFound()
}
const appServer = getRunningExpressApp()
// Look up the flow across ALL workspaces (no workspace filter), then gate strictly on membership.
const chatflow = await appServer.AppDataSource.getRepository(ChatFlow).findOne({
where: { id: chatflowId },
select: ['id', 'workspaceId']
})
if (!chatflow || !chatflow.workspaceId) {
throw notFound()
}
// Direct membership check so the no-leak guarantee holds: identical response whether the flow is
// missing or the user simply isn't a member of its workspace.
const membership = await appServer.AppDataSource.getRepository(WorkspaceUser).findOneBy({
workspaceId: chatflow.workspaceId,
userId
})
Comment thread
dkindlund marked this conversation as resolved.
if (!membership) {
throw notFound()
}
return { workspaceId: chatflow.workspaceId }
} catch (error) {
if (error instanceof InternalFlowiseError) {
throw error
}
throw new InternalFlowiseError(
StatusCodes.INTERNAL_SERVER_ERROR,
`Error: chatflowsService.resolveChatflowWorkspace - ${getErrorMessage(error)}`
)
}
}

/** Resolves a chatflow only if it belongs to the given workspace; rejects when workspaceId is missing (prevents unscoped lookup). */
const getChatflowByIdForWorkspace = async (chatflowId: string, workspaceId: string | undefined): Promise<any> => {
if (!workspaceId) {
Expand Down Expand Up @@ -702,6 +747,7 @@ export default {
getChatflowByApiKey,
getChatflowById,
getChatflowByIdForWorkspace,
resolveChatflowWorkspace,
saveChatflow,
updateChatflow,
getSinglePublicChatbotConfig,
Expand Down
5 changes: 5 additions & 0 deletions packages/ui/src/api/chatflows.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ const getSpecificChatflow = (id) => client.get(`/chatflows/${id}`)

const getSpecificChatflowFromPublicEndpoint = (id) => client.get(`/public-chatflows/${id}`)

// Resolve which workspace owns a flow, but only if the current user is a member (404 otherwise). Used to
// auto-switch the active workspace when opening a flow URL that lives in another (member) workspace.
const getChatflowWorkspace = (id) => client.get(`/chatflows/resolve-workspace/${id}`)

const createNewChatflow = (body) => client.post(`/chatflows`, body)

const updateChatflow = (id, body) => client.put(`/chatflows/${id}`, body)
Expand Down Expand Up @@ -39,6 +43,7 @@ export default {
getAllAgentflows,
getSpecificChatflow,
getSpecificChatflowFromPublicEndpoint,
getChatflowWorkspace,
createNewChatflow,
updateChatflow,
deleteChatflow,
Expand Down
52 changes: 52 additions & 0 deletions packages/ui/src/hooks/useFlowWorkspaceAutoSwitch.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { useCallback, useRef } from 'react'
import { useDispatch, useSelector } from 'react-redux'

import chatflowsApi from '@/api/chatflows'
import workspaceApi from '@/api/workspace'
import { workspaceSwitchSuccess } from '@/store/reducers/authSlice'

// When a flow canvas fails to load with a 404 because the flow lives in a workspace the user belongs to but
// isn't their ACTIVE workspace, resolve the flow's owning workspace (membership-gated server-side) and switch
// to it, then let the caller re-fetch — instead of dead-ending on "Chatflow <id> not found in the database!".
//
// Returns an async `tryAutoSwitch(chatflowId, error, onSwitched)`:
// - returns true => it switched workspaces and invoked onSwitched() (caller should suppress its error)
// - returns false => not applicable / not a member; caller should fall back to its normal error handling
//
// Security: the resolver only returns a workspaceId when the user is a member, and we additionally verify the
// id is in the user's assignedWorkspaces before switching — so we never switch into an unauthorized workspace.
export const useFlowWorkspaceAutoSwitch = () => {
const dispatch = useDispatch()
const currentUser = useSelector((state) => state.auth.user)
const attemptedRef = useRef(new Set())

const tryAutoSwitch = useCallback(
async (chatflowId, error, onSwitched) => {
const status = error?.response?.status
if (status !== 404 || !chatflowId) return false
// Only attempt once per flow id to avoid any retry loop if something goes sideways.
if (attemptedRef.current.has(chatflowId)) return false
attemptedRef.current.add(chatflowId)

try {
const res = await chatflowsApi.getChatflowWorkspace(chatflowId)
const workspaceId = res?.data?.workspaceId
if (!workspaceId) return false
if (workspaceId === currentUser?.activeWorkspaceId) return false
const assigned = currentUser?.assignedWorkspaces || []
if (!assigned.some((w) => w?.id === workspaceId)) return false

const switchRes = await workspaceApi.switchWorkspace(workspaceId)
dispatch(workspaceSwitchSuccess(switchRes?.data))
if (typeof onSwitched === 'function') onSwitched()
return true
} catch (e) {
// Resolver 404 (not a member / missing flow) or any failure → fall back to the normal error.
return false
}
Comment thread
dkindlund marked this conversation as resolved.
},
[currentUser, dispatch]
)

return tryAutoSwitch
}
Comment thread
dkindlund marked this conversation as resolved.
Outdated
12 changes: 11 additions & 1 deletion packages/ui/src/views/agentflowsv2/Canvas.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import chatflowsApi from '@/api/chatflows'

// Hooks
import useApi from '@/hooks/useApi'
import { useFlowWorkspaceAutoSwitch } from '@/hooks/useFlowWorkspaceAutoSwitch'
import useConfirm from '@/hooks/useConfirm'

// icons
Expand Down Expand Up @@ -125,6 +126,7 @@ const AgentflowCanvas = () => {
const createNewChatflowApi = useApi(chatflowsApi.createNewChatflow)
const updateChatflowApi = useApi(chatflowsApi.updateChatflow)
const getSpecificChatflowApi = useApi(chatflowsApi.getSpecificChatflow)
const tryWorkspaceAutoSwitch = useFlowWorkspaceAutoSwitch()

// ==============================|| Events & Actions ||============================== //

Expand Down Expand Up @@ -546,7 +548,15 @@ const AgentflowCanvas = () => {
setEdges(initialFlow.edges || [])
dispatch({ type: SET_CHATFLOW, chatflow })
} else if (getSpecificChatflowApi.error) {
errorFailed(`Failed to retrieve ${canvasTitle}: ${getSpecificChatflowApi.error.response.data.message}`)
const error = getSpecificChatflowApi.error
// If the flow lives in another workspace the user is a member of, auto-switch + re-fetch instead
// of dead-ending on "not found". Falls back to the normal error if it's a genuine 404 / non-member.
tryWorkspaceAutoSwitch(chatflowId, error, () => getSpecificChatflowApi.request(chatflowId)).then((handled) => {
if (!handled) {
const errorMsg = error?.response?.data?.message || error?.message || 'Unknown error'
errorFailed(`Failed to retrieve ${canvasTitle}: ${errorMsg}`)
}
})
Comment thread
dkindlund marked this conversation as resolved.
}

// eslint-disable-next-line react-hooks/exhaustive-deps
Expand Down
12 changes: 11 additions & 1 deletion packages/ui/src/views/canvas/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import chatflowsApi from '@/api/chatflows'

// Hooks
import useApi from '@/hooks/useApi'
import { useFlowWorkspaceAutoSwitch } from '@/hooks/useFlowWorkspaceAutoSwitch'
import useConfirm from '@/hooks/useConfirm'
import { useAuth } from '@/hooks/useAuth'

Expand Down Expand Up @@ -112,6 +113,7 @@ const Canvas = () => {
const createNewChatflowApi = useApi(chatflowsApi.createNewChatflow)
const updateChatflowApi = useApi(chatflowsApi.updateChatflow)
const getSpecificChatflowApi = useApi(chatflowsApi.getSpecificChatflow)
const tryWorkspaceAutoSwitch = useFlowWorkspaceAutoSwitch()
const getHasChatflowChangedApi = useApi(chatflowsApi.getHasChatflowChanged)

// ==============================|| Events & Actions ||============================== //
Expand Down Expand Up @@ -420,7 +422,15 @@ const Canvas = () => {
setEdges(initialFlow.edges || [])
dispatch({ type: SET_CHATFLOW, chatflow })
} else if (getSpecificChatflowApi.error) {
errorFailed(`Failed to retrieve ${canvasTitle}: ${getSpecificChatflowApi.error.response.data.message}`)
const error = getSpecificChatflowApi.error
// If the flow lives in another workspace the user is a member of, auto-switch + re-fetch instead
// of dead-ending on "not found". Falls back to the normal error if it's a genuine 404 / non-member.
tryWorkspaceAutoSwitch(chatflowId, error, () => getSpecificChatflowApi.request(chatflowId)).then((handled) => {
if (!handled) {
const errorMsg = error?.response?.data?.message || error?.message || 'Unknown error'
errorFailed(`Failed to retrieve ${canvasTitle}: ${errorMsg}`)
}
})
Comment thread
dkindlund marked this conversation as resolved.
}

// eslint-disable-next-line react-hooks/exhaustive-deps
Expand Down
Loading