fix(api): echo requested flow identifier in v2 workflow 404 reframes

A denial or owner-override 404 echoed str(flow.id) (the resolved internal
UUID). When the caller referenced the flow by endpoint name, that leaked the
canonical UUID and changed the flow_id in the error body vs the request. The
pre-seam handler echoed the requested identifier; restore that.

authorize_flow_action takes the requested id (LangflowWorkflowHost passes
ResolvedFlow.flow_id, which already holds the caller's value); the owner-gate
reframe uses parsed.flow_id. Adds a test that a denied endpoint-name request
echoes the name, not the UUID. Also documents ResolvedFlow.graph as a
host-defined artifact (Graph for serve, FlowRead for langflow), per review.
This commit is contained in:
ogabrielluiz
2026-06-25 12:00:32 -03:00
parent 08415f7ea8
commit 14368f087e
4 changed files with 47 additions and 11 deletions

View File

@ -146,13 +146,20 @@ async def resolve_flow_for_execution(flow_id: str, current_user: UserRead):
) from err
async def authorize_flow_action(current_user: UserRead, flow, action: WorkflowAction) -> None:
async def authorize_flow_action(
current_user: UserRead, flow, action: WorkflowAction, *, requested_id: str | None = None
) -> None:
"""Map a ``WorkflowAction`` to a ``FlowAction`` and enforce it.
A denied share-aware fetch is reframed to 404 so flow existence is not
leaked through a raw 403.
leaked through a raw 403. ``requested_id`` is the identifier the caller sent
(an endpoint name or a UUID); the reframed body echoes it instead of the
resolved internal UUID. Falls back to ``flow.id`` when not provided.
"""
flow_action = FlowAction.READ if action == WorkflowAction.READ else FlowAction.EXECUTE
# Echo the identifier the caller requested (which may be an endpoint name),
# not the resolved internal UUID, so a denial does not leak the canonical id.
echo_id = requested_id if requested_id is not None else str(flow.id)
try:
await ensure_flow_permission(
current_user,
@ -163,11 +170,11 @@ async def authorize_flow_action(current_user: UserRead, flow, action: WorkflowAc
folder_id=getattr(flow, "folder_id", None),
)
except HTTPException as exc:
privacy = _flow_not_found_privacy_exception(exc, str(flow.id))
privacy = _flow_not_found_privacy_exception(exc, echo_id)
# Preserve the legacy contract: any 404 on this path surfaces as the
# structured FLOW_NOT_FOUND body, not the privacy-reframe's string detail.
if privacy.status_code == status.HTTP_404_NOT_FOUND:
raise _flow_not_found_http_exception(str(flow.id)) from exc
raise _flow_not_found_http_exception(echo_id) from exc
raise privacy from exc
@ -178,9 +185,10 @@ def _apply_execution_gates(parsed, flow, current_user: UserRead) -> None:
_enforce_flow_data_override_owner(parsed, flow, current_user)
except HTTPException as exc:
# The owner-override deny is a privacy 404 (string detail); re-wrap to the
# structured FLOW_NOT_FOUND body to match the legacy single-try contract.
# structured FLOW_NOT_FOUND body using the requested identifier (parsed.flow_id
# may be an endpoint name) so the resolved UUID is not leaked.
if exc.status_code == status.HTTP_404_NOT_FOUND:
raise _flow_not_found_http_exception(str(flow.id)) from exc
raise _flow_not_found_http_exception(str(parsed.flow_id)) from exc
raise
_validate_flow_data_for_execution(parsed, flow)

View File

@ -86,7 +86,7 @@ class LangflowWorkflowHost(WorkflowHostBase):
"""Map the workflow action to a flow action and enforce it (deny -> 404)."""
from langflow.api.v2.workflow import authorize_flow_action
await authorize_flow_action(caller, flow.graph, action)
await authorize_flow_action(caller, flow.graph, action, requested_id=flow.flow_id)
async def run_sync(
self,

View File

@ -337,6 +337,29 @@ class TestV2WorkflowAdmission:
assert exc_info.value.status_code == 404
assert exc_info.value.detail["code"] == "FLOW_NOT_FOUND"
async def test_denial_echoes_requested_identifier_not_resolved_uuid(self, monkeypatch: pytest.MonkeyPatch):
"""A denial on a flow referenced by endpoint name must not leak the resolved UUID."""
from langflow.api.v2 import workflow as workflow_module
from lfx.workflow.actions import WorkflowAction
resolved_uuid = uuid4()
flow = SimpleNamespace(id=resolved_uuid, user_id=uuid4(), workspace_id=None, folder_id=None)
async def _deny(*_args, **_kwargs):
raise HTTPException(status_code=403, detail="denied")
monkeypatch.setattr(workflow_module, "ensure_flow_permission", _deny)
with pytest.raises(HTTPException) as exc_info:
await workflow_module.authorize_flow_action(
SimpleNamespace(id=uuid4()), flow, WorkflowAction.EXECUTE, requested_id="my-endpoint"
)
assert exc_info.value.status_code == 404
assert exc_info.value.detail["code"] == "FLOW_NOT_FOUND"
assert exc_info.value.detail["flow_id"] == "my-endpoint"
assert str(resolved_uuid) not in str(exc_info.value.detail)
def test_private_route_applies_component_policy_gate(self, monkeypatch: pytest.MonkeyPatch):
"""The authenticated v2 route must run server-side component policy validation."""
from langflow.api.v2 import workflow as workflow_module

View File

@ -37,14 +37,19 @@ if TYPE_CHECKING:
class ResolvedFlow(BaseModel):
"""Post-auth, post-RBAC artifact the router runs.
No tenant fields leak into the router body. ``graph`` is the run-ready
``Graph``; the host owns any deepcopy/stamp semantics before handing it back.
No tenant fields leak into the router body. ``flow_id`` is the identifier the
caller requested (an endpoint name or a UUID), kept so error bodies can echo
it instead of the resolved internal id. ``graph`` is a host-defined execution
artifact, NOT necessarily an ``lfx`` ``Graph``: bare serve stores a run-ready
``Graph`` (the default ``run_sync``/``stream_response`` consume it directly),
while the langflow host stores a ``FlowRead`` and overrides those methods. The
host owns any deepcopy/stamp semantics before handing it back.
"""
model_config = {"arbitrary_types_allowed": True}
flow_id: str
graph: Any # the run-ready Graph (deep-copied/stamped by the host)
flow_id: str # the caller-requested identifier (endpoint name or UUID)
graph: Any # host-defined execution artifact (Graph for serve, FlowRead for langflow)
session_id_default: str | None = None # SSE thread_id / session fallback