Skip to content
Merged
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
4 changes: 2 additions & 2 deletions .secrets.baseline
Original file line number Diff line number Diff line change
Expand Up @@ -1441,7 +1441,7 @@
"filename": "src/backend/tests/unit/api/v1/test_projects.py",
"hashed_secret": "8bb6118f8fd6935ad0876a3be34a717d32708ffd",
"is_verified": false,
"line_number": 1997,
"line_number": 2049,
"is_secret": false
}
],
Expand Down Expand Up @@ -7301,5 +7301,5 @@
}
]
},
"generated_at": "2026-08-05T16:25:25Z"
"generated_at": "2026-08-05T19:55:17Z"
}
46 changes: 32 additions & 14 deletions src/backend/base/langflow/api/v1/deployments.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@
count_deployments_by_provider,
delete_deployment_by_id,
get_deployment_by_resource_key,
has_visible_deployment_for_provider,
)
from langflow.services.database.models.deployment.crud import (
create_deployment_from_model as create_deployment_db,
Expand Down Expand Up @@ -792,25 +793,42 @@ async def list_deployments(
)
)

# OSS / no-plugin path keeps the strict owner gate (byte-for-byte the prior
# behavior). Relax it only when the prefilter actually lists ids to surface:
# an empty list means "no extra visibility", so there's nothing a cross-user
# reader could see under a provider they don't own — keep the strict 404 there
# rather than degrade it to an empty 200. A non-empty list resolves the
# provider account by id alone so a shared deployment under another user's
# provider account can be listed; the (owner ⊕ visible) union below still
# governs which rows actually surface.
if visibility_scope is not None and visibility_scope.has_cross_user_access:
# OSS / no-plugin path keeps the strict owner gate. A structured prefilter
# may relax it only after the same SQL visibility predicate used by the page
# proves that this specific provider owns at least one row visible to the
# caller. A coarse workspace/project/global grant alone is not enough:
# loading arbitrary foreign provider UUIDs would expose an existence oracle.
use_shared_provider_lookup = bool(
visibility_scope is not None
and visibility_scope.has_cross_user_access
and await has_visible_deployment_for_provider(
session,
user_id=current_user.id,
deployment_provider_account_id=provider_id,
visibility_scope=visibility_scope,
)
)
if use_shared_provider_lookup:
provider_account = await get_shared_listing_provider_account_or_404(provider_id=provider_id, db=session)
else:
provider_account = await get_owned_provider_account_or_404(
provider_id=provider_id, user_id=current_user.id, db=session
)
await ensure_deployment_permission(
current_user,
DeploymentAction.READ,
project_id=project_id,
)
try:
await ensure_deployment_permission(
current_user,
DeploymentAction.READ,
project_id=project_id,
)
except HTTPException as exc:
if use_shared_provider_lookup:
# The relaxed provider-account lookup above loads by UUID alone so
# shared deployments can be listed. Once that path is active, mask
# a policy deny exactly like a missing account; otherwise callers
# could distinguish an existing foreign provider UUID (403) from a
# nonexistent UUID (404).
raise deny_to_404(exc, detail="Deployment provider account not found.") from exc
raise
deployment_adapter = resolve_deployment_adapter(provider_account.provider_key)
deployment_mapper = get_deployment_mapper(provider_account.provider_key)
if load_from_provider:
Expand Down
22 changes: 19 additions & 3 deletions src/backend/base/langflow/api/v1/projects.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,11 +60,13 @@
from langflow.services.database.models.folder.model import (
Folder,
FolderCreate,
FolderListRead,
FolderRead,
FolderReadWithFlows,
FolderUpdate,
)
from langflow.services.database.models.folder.pagination_model import FolderWithPaginatedFlows
from langflow.services.database.models.user.model import User
from langflow.services.deps import get_service, get_settings_service
from langflow.services.schema import ServiceType

Expand Down Expand Up @@ -220,7 +222,7 @@ async def _move_flows_into_project() -> None:
return folder_read


@router.get("/", response_model=list[FolderRead], status_code=200)
@router.get("/", response_model=list[FolderListRead], status_code=200)
async def read_projects(
*,
session: DbSession,
Expand Down Expand Up @@ -272,8 +274,22 @@ async def read_projects(
)
sorted_projects = sorted(projects, key=lambda x: x.name != DEFAULT_FOLDER_NAME)

# Convert to FolderRead while session is still active to avoid detached instance errors
return [FolderRead.model_validate(project, from_attributes=True) for project in sorted_projects]
owner_ids = {project.user_id for project in sorted_projects if project.user_id is not None}
owners_by_id: dict[str, str] = {}
if owner_ids:
owner_rows = (await session.exec(select(User.id, User.username).where(User.id.in_(owner_ids)))).all()
owners_by_id = {str(owner_id): username for owner_id, username in owner_rows}

# Convert while the session is active so owner-qualified project lists
# do not trigger lazy loads after the request-scoped session closes.
return [
FolderListRead(
**FolderRead.model_validate(project, from_attributes=True).model_dump(),
owner_username=owners_by_id.get(str(project.user_id)) if project.user_id is not None else None,
is_owner=str(project.user_id) == str(current_user.id),
)
for project in sorted_projects
]
except Exception as e:
raise HTTPException(status_code=500, detail=str(e)) from e

Expand Down
36 changes: 33 additions & 3 deletions src/backend/base/langflow/services/authorization/listing.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from typing import TYPE_CHECKING, Any, TypeVar

from lfx.services.authorization.base import ResourceVisibilityScope
from sqlalchemy import Select, false
from sqlalchemy import Select, and_, false
from sqlmodel import col, or_

from langflow.services.authorization.actions import FlowAction
Expand Down Expand Up @@ -267,8 +267,35 @@ def restrict_to_owned_or_visible_scope(
resolved_workspace = workspace_expression
if resolved_workspace is None and workspace_column is not None:
resolved_workspace = col(workspace_column)
workspace_project_allowed: ColumnElement[bool] | None = None
if project_column is not None and visibility.excluded_workspace_project_ids:
# A workspace-only resource has no project to exclude. Keep it visible
# for an explicit workspace grant while excluding resources attached to
# reserved projects. The explicit ``IS NULL`` branch also keeps SQL's
# three-valued NULL semantics aligned with ``resource_visible_in_scope``.
workspace_project_allowed = or_(
col(project_column).is_(None),
col(project_column).not_in(visibility.excluded_workspace_project_ids),
)
if resolved_workspace is not None and visibility.workspace_ids:
clauses.append(resolved_workspace.in_(visibility.workspace_ids))
workspace_clause = resolved_workspace.in_(visibility.workspace_ids)
if workspace_project_allowed is not None:
workspace_clause = and_(workspace_clause, workspace_project_allowed)
clauses.append(workspace_clause)
if resolved_workspace is not None and project_column is not None and visibility.include_unassigned_workspace:
# The logical unassigned workspace contains projects whose stored
# workspace is NULL; it does not contain folderless/workspace-less
# resources. Requiring a concrete project keeps list filtering aligned
# with direct authorization, which resolves this scope through the
# resource's project relation.
unassigned_project_allowed = col(project_column).is_not(None)
if visibility.excluded_workspace_project_ids:
unassigned_project_allowed = and_(
unassigned_project_allowed,
col(project_column).not_in(visibility.excluded_workspace_project_ids),
)
workspace_clause = and_(resolved_workspace.is_(None), unassigned_project_allowed)
clauses.append(workspace_clause)
if project_column is not None and visibility.project_ids:
clauses.append(col(project_column).in_(visibility.project_ids))
return stmt.where(or_(*clauses))
Expand Down Expand Up @@ -305,9 +332,12 @@ def resource_visible_in_scope(
project_id: UUID | None = None,
) -> bool:
"""Evaluate a compact visibility scope for an already-loaded resource."""
workspace_project_allowed = project_id is None or project_id not in visibility.excluded_workspace_project_ids
unassigned_project_allowed = project_id is not None and project_id not in visibility.excluded_workspace_project_ids
return bool(
visibility.all_resources
or resource_id in visibility.resource_ids
or (workspace_id is not None and workspace_id in visibility.workspace_ids)
or (workspace_project_allowed and workspace_id is not None and workspace_id in visibility.workspace_ids)
or (unassigned_project_allowed and workspace_id is None and visibility.include_unassigned_workspace)
or (project_id is not None and project_id in visibility.project_ids)
)
Original file line number Diff line number Diff line change
Expand Up @@ -407,6 +407,31 @@ async def _scope_to_owner_or_allowed(
)


async def has_visible_deployment_for_provider(
db: AsyncSession,
*,
user_id: UUID,
deployment_provider_account_id: UUID,
visibility_scope: ResourceVisibilityScope,
) -> bool:
"""Return whether this provider owns a deployment visible to the caller.

Cross-user deployment listing must resolve a foreign provider account to
select its adapter, but loading an arbitrary account by UUID creates an
existence oracle. Bind that relaxed lookup to the same owner/visibility
predicate used by the page and count queries, scoped to the requested
provider, before any provider metadata is loaded.
"""
stmt = select(Deployment.id).where(Deployment.deployment_provider_account_id == deployment_provider_account_id)
stmt = await _scope_to_owner_or_allowed(
stmt,
user_id=user_id,
allowed_ids=None,
visibility_scope=visibility_scope,
)
return (await db.exec(stmt.limit(1))).first() is not None


async def list_deployments_page(
db: AsyncSession,
*,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,11 @@ class FolderRead(FolderBase):
parent_id: UUID | None = Field()


class FolderListRead(FolderRead):
owner_username: str | None = None
is_owner: bool


class FolderReadWithFlows(FolderBase):
id: UUID
parent_id: UUID | None = Field()
Expand Down
84 changes: 84 additions & 0 deletions src/backend/tests/unit/api/v1/test_deployment_route_handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -872,16 +872,96 @@ def _apply_metadata_batch(*_args, **kwargs):


class TestListDeploymentsSharedPrefilter:
@pytest.mark.asyncio
@patch(f"{ROUTES_MODULE}.resolve_deployment_adapter")
@patch(f"{ROUTES_MODULE}.ensure_deployment_permission", new_callable=AsyncMock)
@patch(f"{ROUTES_MODULE}.has_visible_deployment_for_provider", new_callable=AsyncMock)
@patch(f"{ROUTES_MODULE}.get_shared_listing_provider_account_or_404", new_callable=AsyncMock)
@patch(f"{ROUTES_MODULE}.get_owned_provider_account_or_404", new_callable=AsyncMock)
@patch(f"{ROUTES_MODULE}.visible_scope_prefilter", new_callable=AsyncMock)
async def test_cross_user_provider_permission_deny_is_masked_as_not_found(
self,
mock_prefilter,
mock_get_owned_pa,
mock_get_shared_pa,
mock_has_visible_deployment,
mock_ensure_permission,
mock_resolve_adapter,
):
"""A relaxed provider lookup must not reveal whether a foreign UUID exists."""
from langflow.api.v1.deployments import list_deployments

provider_account = _fake_provider_account()
mock_prefilter.return_value = ResourceVisibilityScope(include_unassigned_workspace=True)
mock_has_visible_deployment.return_value = True
mock_get_shared_pa.return_value = provider_account
mock_ensure_permission.side_effect = HTTPException(status_code=403, detail="Not authorized")

with pytest.raises(HTTPException) as exc_info:
await list_deployments(
provider_id=provider_account.id,
session=MagicMock(),
current_user=_fake_user(),
params=SimpleNamespace(page=1, size=20),
deployment_type=None,
)

assert exc_info.value.status_code == 404
assert exc_info.value.detail == "Deployment provider account not found."
mock_get_owned_pa.assert_not_awaited()
mock_get_shared_pa.assert_awaited_once()
mock_resolve_adapter.assert_not_called()

@pytest.mark.asyncio
@patch(f"{ROUTES_MODULE}.has_visible_deployment_for_provider", new_callable=AsyncMock)
@patch(f"{ROUTES_MODULE}.get_shared_listing_provider_account_or_404", new_callable=AsyncMock)
@patch(f"{ROUTES_MODULE}.get_owned_provider_account_or_404", new_callable=AsyncMock)
@patch(f"{ROUTES_MODULE}.visible_scope_prefilter", new_callable=AsyncMock)
async def test_unrelated_foreign_provider_never_reaches_unscoped_lookup(
self,
mock_prefilter,
mock_get_owned_pa,
mock_get_shared_pa,
mock_has_visible_deployment,
):
"""A coarse scope cannot reveal a provider with no deployment visible in that scope."""
from langflow.api.v1.deployments import list_deployments

provider_id = uuid4()
mock_prefilter.return_value = ResourceVisibilityScope(workspace_ids=(uuid4(),))
mock_has_visible_deployment.return_value = False
mock_get_owned_pa.side_effect = HTTPException(
status_code=404,
detail="Deployment provider account not found.",
)

with pytest.raises(HTTPException) as exc_info:
await list_deployments(
provider_id=provider_id,
session=MagicMock(),
current_user=_fake_user(),
params=SimpleNamespace(page=1, size=20),
deployment_type=None,
)

assert exc_info.value.status_code == 404
assert exc_info.value.detail == "Deployment provider account not found."
mock_has_visible_deployment.assert_awaited_once()
mock_get_owned_pa.assert_awaited_once()
mock_get_shared_pa.assert_not_awaited()

@pytest.mark.asyncio
@patch(f"{ROUTES_MODULE}.list_deployments_synced", new_callable=AsyncMock)
@patch(f"{ROUTES_MODULE}.resolve_deployment_adapter")
@patch(f"{ROUTES_MODULE}.get_deployment_mapper")
@patch(f"{ROUTES_MODULE}.get_shared_listing_provider_account_or_404", new_callable=AsyncMock)
@patch(f"{ROUTES_MODULE}.get_owned_provider_account_or_404", new_callable=AsyncMock)
@patch(f"{ROUTES_MODULE}.has_visible_deployment_for_provider", new_callable=AsyncMock)
@patch(f"{ROUTES_MODULE}.visible_scope_prefilter", new_callable=AsyncMock)
async def test_concrete_prefilter_relaxes_provider_gate_and_threads_visibility_scope(
self,
mock_prefilter,
mock_has_visible_deployment,
mock_get_owned_pa,
mock_get_shared_pa,
mock_get_mapper,
Expand All @@ -906,6 +986,7 @@ async def test_concrete_prefilter_relaxes_provider_gate_and_threads_visibility_s

scope = ResourceVisibilityScope(resource_ids=(shared_id,))
mock_prefilter.return_value = scope
mock_has_visible_deployment.return_value = True
mock_get_shared_pa.return_value = pa
mock_resolve_adapter.return_value = AsyncMock()
mapper = MagicMock()
Expand Down Expand Up @@ -4070,10 +4151,12 @@ class TestListDeploymentsScopedKeyPrefilter:
@patch(f"{ROUTES_MODULE}.get_deployment_mapper")
@patch(f"{ROUTES_MODULE}.get_shared_listing_provider_account_or_404", new_callable=AsyncMock)
@patch(f"{ROUTES_MODULE}.get_owned_provider_account_or_404", new_callable=AsyncMock)
@patch(f"{ROUTES_MODULE}.has_visible_deployment_for_provider", new_callable=AsyncMock)
@patch(f"{ROUTES_MODULE}.visible_scope_prefilter", new_callable=AsyncMock)
async def test_concrete_prefilter_threads_visibility_scope_without_owner_union_assumption(
self,
mock_prefilter,
mock_has_visible_deployment,
mock_get_owned_pa,
mock_get_shared_pa,
mock_get_mapper,
Expand All @@ -4093,6 +4176,7 @@ async def test_concrete_prefilter_threads_visibility_scope_without_owner_union_a
visibility_scope = ResourceVisibilityScope(resource_ids=(visible_only,))
pa = _fake_provider_account()
mock_prefilter.return_value = visibility_scope
mock_has_visible_deployment.return_value = True
mock_get_shared_pa.return_value = pa
mock_resolve_adapter.return_value = AsyncMock()
mapper = MagicMock()
Expand Down
Loading
Loading