mirror of
https://github.com/langflow-ai/langflow.git
synced 2026-07-23 16:10:27 +08:00
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:
@ -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)
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user