Skip to content

Commit 23f91d8

Browse files
authored
fix(authz): support scoped project visibility (langflow-ai#14429)
* fix(authz): support scoped project visibility * test: cover shared project edge cases * fix(authz): address project visibility review * fix(authz): enforce reserved project scope boundaries
1 parent 18f9875 commit 23f91d8

45 files changed

Lines changed: 1610 additions & 394 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.secrets.baseline

Lines changed: 115 additions & 115 deletions
Large diffs are not rendered by default.

src/backend/base/langflow/api/v1/deployments.py

Lines changed: 32 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@
8484
count_deployments_by_provider,
8585
delete_deployment_by_id,
8686
get_deployment_by_resource_key,
87+
has_visible_deployment_for_provider,
8788
)
8889
from langflow.services.database.models.deployment.crud import (
8990
create_deployment_from_model as create_deployment_db,
@@ -792,25 +793,42 @@ async def list_deployments(
792793
)
793794
)
794795

795-
# OSS / no-plugin path keeps the strict owner gate (byte-for-byte the prior
796-
# behavior). Relax it only when the prefilter actually lists ids to surface:
797-
# an empty list means "no extra visibility", so there's nothing a cross-user
798-
# reader could see under a provider they don't own — keep the strict 404 there
799-
# rather than degrade it to an empty 200. A non-empty list resolves the
800-
# provider account by id alone so a shared deployment under another user's
801-
# provider account can be listed; the (owner ⊕ visible) union below still
802-
# governs which rows actually surface.
803-
if visibility_scope is not None and visibility_scope.has_cross_user_access:
796+
# OSS / no-plugin path keeps the strict owner gate. A structured prefilter
797+
# may relax it only after the same SQL visibility predicate used by the page
798+
# proves that this specific provider owns at least one row visible to the
799+
# caller. A coarse workspace/project/global grant alone is not enough:
800+
# loading arbitrary foreign provider UUIDs would expose an existence oracle.
801+
use_shared_provider_lookup = bool(
802+
visibility_scope is not None
803+
and visibility_scope.has_cross_user_access
804+
and await has_visible_deployment_for_provider(
805+
session,
806+
user_id=current_user.id,
807+
deployment_provider_account_id=provider_id,
808+
visibility_scope=visibility_scope,
809+
)
810+
)
811+
if use_shared_provider_lookup:
804812
provider_account = await get_shared_listing_provider_account_or_404(provider_id=provider_id, db=session)
805813
else:
806814
provider_account = await get_owned_provider_account_or_404(
807815
provider_id=provider_id, user_id=current_user.id, db=session
808816
)
809-
await ensure_deployment_permission(
810-
current_user,
811-
DeploymentAction.READ,
812-
project_id=project_id,
813-
)
817+
try:
818+
await ensure_deployment_permission(
819+
current_user,
820+
DeploymentAction.READ,
821+
project_id=project_id,
822+
)
823+
except HTTPException as exc:
824+
if use_shared_provider_lookup:
825+
# The relaxed provider-account lookup above loads by UUID alone so
826+
# shared deployments can be listed. Once that path is active, mask
827+
# a policy deny exactly like a missing account; otherwise callers
828+
# could distinguish an existing foreign provider UUID (403) from a
829+
# nonexistent UUID (404).
830+
raise deny_to_404(exc, detail="Deployment provider account not found.") from exc
831+
raise
814832
deployment_adapter = resolve_deployment_adapter(provider_account.provider_key)
815833
deployment_mapper = get_deployment_mapper(provider_account.provider_key)
816834
if load_from_provider:

src/backend/base/langflow/api/v1/flows.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,9 @@ async def read_flows(
173173
default_folder = (await session.exec(select(Folder).where(Folder.name == DEFAULT_FOLDER_NAME))).first()
174174
default_folder_id = default_folder.id if default_folder else None
175175

176-
starter_folder = (await session.exec(select(Folder).where(Folder.name == STARTER_FOLDER_NAME))).first()
176+
starter_folder = (
177+
await session.exec(select(Folder).where(Folder.name == STARTER_FOLDER_NAME, Folder.user_id.is_(None)))
178+
).first()
177179
starter_folder_id = starter_folder.id if starter_folder else None
178180

179181
if not starter_folder and not default_folder:
@@ -1060,7 +1062,11 @@ async def read_basic_examples(
10601062
cached_flow_reads = _starter_flows_cache.get("starter_flows")
10611063
if cached_flow_reads is CACHE_MISS:
10621064
try:
1063-
starter_folder = (await session.exec(select(Folder).where(Folder.name == STARTER_FOLDER_NAME))).first()
1065+
starter_folder = (
1066+
await session.exec(
1067+
select(Folder).where(Folder.name == STARTER_FOLDER_NAME, Folder.user_id.is_(None))
1068+
)
1069+
).first()
10641070

10651071
if not starter_folder:
10661072
return compress_response([])

src/backend/base/langflow/api/v1/projects.py

Lines changed: 49 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from lfx.log.logger import logger
99
from lfx.services.mcp_composer.service import MCPComposerService
1010
from lfx.utils.util_strings import escape_like_pattern
11-
from sqlalchemy import literal, or_, update
11+
from sqlalchemy import literal, null, or_, update
1212
from sqlalchemy.orm import selectinload
1313
from sqlmodel import select
1414

@@ -60,11 +60,13 @@
6060
from langflow.services.database.models.folder.model import (
6161
Folder,
6262
FolderCreate,
63+
FolderListRead,
6364
FolderRead,
6465
FolderReadWithFlows,
6566
FolderUpdate,
6667
)
6768
from langflow.services.database.models.folder.pagination_model import FolderWithPaginatedFlows
69+
from langflow.services.database.models.user.model import User
6870
from langflow.services.deps import get_service, get_settings_service
6971
from langflow.services.schema import ServiceType
7072

@@ -220,7 +222,7 @@ async def _move_flows_into_project() -> None:
220222
return folder_read
221223

222224

223-
@router.get("/", response_model=list[FolderRead], status_code=200)
225+
@router.get("/", response_model=list[FolderListRead], status_code=200)
224226
async def read_projects(
225227
*,
226228
session: DbSession,
@@ -254,26 +256,45 @@ async def read_projects(
254256
else:
255257
stmt = select(Folder).where(or_(owned_clause, Folder.user_id == None)) # noqa: E711
256258
projects = (await session.exec(stmt)).all()
257-
projects = [project for project in projects if project.name != STARTER_FOLDER_NAME]
259+
projects = [
260+
project for project in projects if not (project.name == STARTER_FOLDER_NAME and project.user_id is None)
261+
]
258262
# When no DB prefilter is available (OSS pass-through), drop projects the
259263
# user can't read in memory. ``domain_extractor`` groups requests by
260-
# workspace so each batch is evaluated against the right policy tuple
261-
# (projects are the resource itself, so the domain falls back to
262-
# workspace or ``*``). When the prefilter is active the SQL union is
263-
# already authoritative — skip the per-row enforce to avoid an N+1.
264+
# concrete project so each batch is evaluated against the same policy
265+
# tuple as the single-resource guard. When the prefilter is active the
266+
# SQL union is already authoritative — skip the per-row enforce to
267+
# avoid an N+1.
264268
if visibility_scope is None:
265269
projects = await filter_visible_resources(
266270
current_user,
267271
resource_type="project",
268272
candidates=list(projects),
269-
domain_extractor=lambda project: _resolve_authz_domain(project.workspace_id, None),
273+
domain_extractor=lambda project: _resolve_authz_domain(project.workspace_id, project.id),
270274
owner_extractor=lambda project: project.user_id,
271275
act=ProjectAction.READ,
272276
)
273277
sorted_projects = sorted(projects, key=lambda x: x.name != DEFAULT_FOLDER_NAME)
274278

275-
# Convert to FolderRead while session is still active to avoid detached instance errors
276-
return [FolderRead.model_validate(project, from_attributes=True) for project in sorted_projects]
279+
owner_ids = {project.user_id for project in sorted_projects if project.user_id is not None}
280+
owners_by_id: dict[str, str] = {}
281+
if owner_ids:
282+
owner_rows = (await session.exec(select(User.id, User.username).where(User.id.in_(owner_ids)))).all()
283+
owners_by_id = {str(owner_id): username for owner_id, username in owner_rows}
284+
285+
# Convert while the session is active so owner-qualified project lists
286+
# do not trigger lazy loads after the request-scoped session closes.
287+
return [
288+
FolderListRead.model_validate(
289+
project,
290+
from_attributes=True,
291+
update={
292+
"owner_username": owners_by_id.get(str(project.user_id)) if project.user_id is not None else None,
293+
"is_owner": str(project.user_id) == str(current_user.id),
294+
},
295+
)
296+
for project in sorted_projects
297+
]
277298
except Exception as e:
278299
raise HTTPException(status_code=500, detail=str(e)) from e
279300

@@ -365,7 +386,7 @@ async def read_project(
365386
stmt,
366387
id_column=Flow.id,
367388
owner_clause=Flow.user_id == current_user.id,
368-
workspace_expression=literal(project.workspace_id),
389+
workspace_expression=null() if project.workspace_id is None else literal(project.workspace_id),
369390
project_column=Flow.folder_id,
370391
visibility=visibility_scope,
371392
)
@@ -490,6 +511,17 @@ async def update_project(
490511
except HTTPException as exc:
491512
raise deny_to_404(exc, detail="Project not found") from exc
492513

514+
if (
515+
project.name is not None
516+
and project.name != existing_project.name
517+
and existing_project.name == STARTER_FOLDER_NAME
518+
and existing_project.user_id is None
519+
):
520+
raise HTTPException(
521+
status_code=status.HTTP_403_FORBIDDEN,
522+
detail=f"The system-managed '{STARTER_FOLDER_NAME}' project cannot be renamed.",
523+
)
524+
493525
# Flow rollup uses the project owner — a non-owner editing a shared
494526
# project must touch the owner's flows, not the actor's same-folder
495527
# flows (which would be empty for a non-owner anyway).
@@ -723,10 +755,12 @@ async def _load_project() -> Folder | None:
723755
except HTTPException as exc:
724756
raise deny_to_404(exc, detail="Project not found") from exc
725757

726-
# Prevent deletion of the Langflow Assistant folder
727-
if project.name == ASSISTANT_FOLDER_NAME:
728-
msg = f"Cannot delete the '{ASSISTANT_FOLDER_NAME}' folder, that contains pre-built flows."
729-
await logger.adebug("Cannot delete the '%s' folder, that contains pre-built flows.", ASSISTANT_FOLDER_NAME)
758+
# Prevent deletion of projects managed by Langflow. The ownerless Starter
759+
# Project is also a stable authorization boundary for bundled examples.
760+
is_system_starter = project.name == STARTER_FOLDER_NAME and project.user_id is None
761+
if project.name == ASSISTANT_FOLDER_NAME or is_system_starter:
762+
msg = f"Cannot delete the '{project.name}' folder, which contains pre-built flows."
763+
await logger.adebug("Cannot delete the '%s' folder, which contains pre-built flows.", project.name)
730764
raise HTTPException(
731765
status_code=403,
732766
detail=msg,

src/backend/base/langflow/initial_setup/setup.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -845,7 +845,7 @@ async def delete_starter_projects(session, folder_id) -> None:
845845

846846

847847
async def folder_exists(session, folder_name):
848-
stmt = select(Folder).where(Folder.name == folder_name)
848+
stmt = select(Folder).where(Folder.name == folder_name, Folder.user_id.is_(None))
849849
folder = (await session.exec(stmt)).first()
850850
return folder is not None
851851

@@ -858,7 +858,7 @@ async def get_or_create_starter_folder(session):
858858
await session.flush()
859859
await session.refresh(db_folder)
860860
return db_folder
861-
stmt = select(Folder).where(Folder.name == STARTER_FOLDER_NAME)
861+
stmt = select(Folder).where(Folder.name == STARTER_FOLDER_NAME, Folder.user_id.is_(None))
862862
return (await session.exec(stmt)).first()
863863

864864

src/backend/base/langflow/services/authorization/guards.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -321,7 +321,11 @@ class _ResourceSpec:
321321
owner_kw="project_user_id",
322322
id_kw="project_id",
323323
workspace_kw="workspace_id",
324-
scope_kw=None,
324+
# Existing-project checks use the concrete project domain so plugins
325+
# can distinguish reserved projects from their parent workspace.
326+
# CREATE has no project id and still resolves to the workspace/global
327+
# domain as before.
328+
scope_kw="project_id",
325329
),
326330
"knowledge_base": _ResourceSpec(
327331
resource_type="knowledge_base",

src/backend/base/langflow/services/authorization/listing.py

Lines changed: 51 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from typing import TYPE_CHECKING, Any, TypeVar
66

77
from lfx.services.authorization.base import ResourceVisibilityScope
8-
from sqlalchemy import Select, false
8+
from sqlalchemy import Select, and_, false
99
from sqlmodel import col, or_
1010

1111
from langflow.services.authorization.actions import FlowAction
@@ -259,16 +259,56 @@ def restrict_to_owned_or_visible_scope(
259259
) -> StatementT:
260260
"""Apply owner, concrete-ID, workspace, and project visibility before pagination."""
261261
if visibility.all_resources:
262-
return stmt
262+
if project_column is None or not visibility.excluded_global_project_ids:
263+
return stmt
264+
# Global role access can exclude reserved projects without enumerating
265+
# every visible resource. Ownership and concrete grants remain additive,
266+
# so users retain their own resources and directly shared resources in
267+
# an otherwise excluded project. Folderless resources remain global.
268+
global_clauses: list[ColumnElement[bool]] = [
269+
owner_clause,
270+
col(project_column).is_(None),
271+
col(project_column).not_in(visibility.excluded_global_project_ids),
272+
]
273+
if visibility.resource_ids:
274+
global_clauses.append(col(id_column).in_(visibility.resource_ids))
275+
return stmt.where(or_(*global_clauses))
263276

264277
clauses: list[ColumnElement[bool]] = [owner_clause]
265278
if visibility.resource_ids:
266279
clauses.append(col(id_column).in_(visibility.resource_ids))
267280
resolved_workspace = workspace_expression
268281
if resolved_workspace is None and workspace_column is not None:
269282
resolved_workspace = col(workspace_column)
283+
workspace_project_allowed: ColumnElement[bool] | None = None
284+
if project_column is not None and visibility.excluded_workspace_project_ids:
285+
# A workspace-only resource has no project to exclude. Keep it visible
286+
# for an explicit workspace grant while excluding resources attached to
287+
# reserved projects. The explicit ``IS NULL`` branch also keeps SQL's
288+
# three-valued NULL semantics aligned with ``resource_visible_in_scope``.
289+
workspace_project_allowed = or_(
290+
col(project_column).is_(None),
291+
col(project_column).not_in(visibility.excluded_workspace_project_ids),
292+
)
270293
if resolved_workspace is not None and visibility.workspace_ids:
271-
clauses.append(resolved_workspace.in_(visibility.workspace_ids))
294+
workspace_clause = resolved_workspace.in_(visibility.workspace_ids)
295+
if workspace_project_allowed is not None:
296+
workspace_clause = and_(workspace_clause, workspace_project_allowed)
297+
clauses.append(workspace_clause)
298+
if resolved_workspace is not None and project_column is not None and visibility.include_unassigned_workspace:
299+
# The logical unassigned workspace contains projects whose stored
300+
# workspace is NULL; it does not contain folderless/workspace-less
301+
# resources. Requiring a concrete project keeps list filtering aligned
302+
# with direct authorization, which resolves this scope through the
303+
# resource's project relation.
304+
unassigned_project_allowed = col(project_column).is_not(None)
305+
if visibility.excluded_workspace_project_ids:
306+
unassigned_project_allowed = and_(
307+
unassigned_project_allowed,
308+
col(project_column).not_in(visibility.excluded_workspace_project_ids),
309+
)
310+
workspace_clause = and_(resolved_workspace.is_(None), unassigned_project_allowed)
311+
clauses.append(workspace_clause)
272312
if project_column is not None and visibility.project_ids:
273313
clauses.append(col(project_column).in_(visibility.project_ids))
274314
return stmt.where(or_(*clauses))
@@ -305,9 +345,15 @@ def resource_visible_in_scope(
305345
project_id: UUID | None = None,
306346
) -> bool:
307347
"""Evaluate a compact visibility scope for an already-loaded resource."""
348+
globally_visible = visibility.all_resources and (
349+
project_id is None or project_id not in visibility.excluded_global_project_ids
350+
)
351+
workspace_project_allowed = project_id is None or project_id not in visibility.excluded_workspace_project_ids
352+
unassigned_project_allowed = project_id is not None and project_id not in visibility.excluded_workspace_project_ids
308353
return bool(
309-
visibility.all_resources
354+
globally_visible
310355
or resource_id in visibility.resource_ids
311-
or (workspace_id is not None and workspace_id in visibility.workspace_ids)
356+
or (workspace_project_allowed and workspace_id is not None and workspace_id in visibility.workspace_ids)
357+
or (unassigned_project_allowed and workspace_id is None and visibility.include_unassigned_workspace)
312358
or (project_id is not None and project_id in visibility.project_ids)
313359
)

src/backend/base/langflow/services/database/models/deployment/crud.py

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -397,16 +397,51 @@ async def _scope_to_owner_or_allowed(
397397
visible_ids=allowed_ids or (),
398398
)
399399

400+
from langflow.services.database.models.folder.model import Folder
401+
402+
# ``Deployment.workspace_id`` was introduced as denormalized plumbing and
403+
# is absent on legacy rows (and on rows created through the current CRUD
404+
# helper). Resolve the workspace from the authoritative project relation so
405+
# Default-workspace access cannot absorb deployments whose project belongs
406+
# to an explicit workspace.
407+
project_workspace = (
408+
select(Folder.workspace_id).where(Folder.id == Deployment.project_id).correlate(Deployment).scalar_subquery()
409+
)
400410
return await apply_owned_or_visible_scope_prefilter(
401411
stmt,
402412
id_column=Deployment.id,
403413
owner_clause=Deployment.user_id == user_id,
404-
workspace_column=Deployment.workspace_id,
414+
workspace_expression=project_workspace,
405415
project_column=Deployment.project_id,
406416
visibility=visibility_scope,
407417
)
408418

409419

420+
async def has_visible_deployment_for_provider(
421+
db: AsyncSession,
422+
*,
423+
user_id: UUID,
424+
deployment_provider_account_id: UUID,
425+
visibility_scope: ResourceVisibilityScope,
426+
) -> bool:
427+
"""Return whether this provider owns a deployment visible to the caller.
428+
429+
Cross-user deployment listing must resolve a foreign provider account to
430+
select its adapter, but loading an arbitrary account by UUID creates an
431+
existence oracle. Bind that relaxed lookup to the same owner/visibility
432+
predicate used by the page and count queries, scoped to the requested
433+
provider, before any provider metadata is loaded.
434+
"""
435+
stmt = select(Deployment.id).where(Deployment.deployment_provider_account_id == deployment_provider_account_id)
436+
stmt = await _scope_to_owner_or_allowed(
437+
stmt,
438+
user_id=user_id,
439+
allowed_ids=None,
440+
visibility_scope=visibility_scope,
441+
)
442+
return (await db.exec(stmt.limit(1))).first() is not None
443+
444+
410445
async def list_deployments_page(
411446
db: AsyncSession,
412447
*,

0 commit comments

Comments
 (0)