Skip to content
Open
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
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
48 changes: 48 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, WorkspaceUserStatus } from '../../enterprise/database/entities/workspace-user.entity'
import { getWorkspaceSearchOptions } from '../../enterprise/utils/ControllerServiceUtils'
import { InternalFlowiseError } from '../../errors/internalFlowiseError'
import { getErrorMessage } from '../../errors/utils'
Expand Down Expand Up @@ -304,6 +305,52 @@ 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 an active member of its workspace. Require ACTIVE status so a
// user who is only INVITED (not yet accepted) or DISABLEd cannot resolve or switch into it.
const membership = await appServer.AppDataSource.getRepository(WorkspaceUser).findOneBy({
workspaceId: chatflow.workspaceId,
userId,
status: WorkspaceUserStatus.ACTIVE
})
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 +749,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
70 changes: 70 additions & 0 deletions packages/ui/src/hooks/useFlowWorkspaceAutoSwitch.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { useCallback, useEffect, 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)
// Keep the latest user in a ref so tryAutoSwitch can read it without being listed as a useCallback dep.
// This keeps the callback identity stable (callers omit it from their useEffect deps) while still reading
// the freshest user — avoiding a stale closure if the user loads/changes after the callback was created.
const currentUserRef = useRef(currentUser)
useEffect(() => {
currentUserRef.current = currentUser
}, [currentUser])

const attemptedRef = useRef(new Set())

const tryAutoSwitch = useCallback(
async (chatflowId, error, onSwitched) => {
const status = error?.response?.status
if (status !== 404 || !chatflowId) return false

// Bail before marking attempted if the user isn't loaded yet, so a later (loaded) call can retry.
const user = currentUserRef.current
if (!user) 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 === user.activeWorkspaceId) return false
const assigned = user.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) {
// A 404 is definitive (not a member / missing flow) — leave it marked so we don't retry.
// For transient failures (network error, 5xx, etc.) clear the mark so a later re-fetch can retry.
if (e?.response?.status !== 404) {
attemptedRef.current.delete(chatflowId)
}
// Fall back to the caller's normal error handling.
return false
}
Comment thread
dkindlund marked this conversation as resolved.
},
[dispatch]
)

return tryAutoSwitch
}
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