Skip to content

Commit d61c5cb

Browse files
authored
feat(catalog): filter starter templates by policy (#14321)
* feat(catalog): filter starter templates by policy * feat(catalog): enforce component policy at runtime (#14322)
1 parent 2dd7e91 commit d61c5cb

12 files changed

Lines changed: 597 additions & 21 deletions

File tree

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from lfx.utils.flow_validation import (
1717
CustomComponentValidationError,
1818
prepare_public_flow_build,
19+
validate_catalog_policy_for_flow,
1920
validate_flow_for_current_settings,
2021
validate_public_flow_no_code_execution,
2122
)
@@ -850,6 +851,13 @@ async def build_public_tmp(
850851
async with session_scope() as session:
851852
flow = await session.get(Flow, flow_id)
852853
if flow and flow.data:
854+
# The default anonymous build path sanitizes component code directly
855+
# and therefore does not call validate_flow_for_current_settings.
856+
# Enforce the exact catalog snapshot after the public-access check
857+
# and before any graph is queued or built. The explicit public-custom
858+
# opt-in already runs the unified validator inside prepare_public_flow_build.
859+
if not settings.allow_public_custom_components:
860+
validate_catalog_policy_for_flow(flow.data)
853861
# Block unauthenticated builds of flows that run arbitrary code
854862
# (Python interpreter/REPL, legacy Python Code Structured tool,
855863
# Smart Transform lambda) or invoke another saved flow (Run Flow,

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

Lines changed: 49 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import io
55
import threading
66
import zipfile
7+
from collections.abc import Collection
78
from typing import Annotated
89
from uuid import UUID
910

@@ -49,7 +50,7 @@
4950
from langflow.api.v1.mappers.deployments.sync import retry_flow_operation_on_deployment_guard
5051
from langflow.api.v1.schemas import FlowListCreate
5152
from langflow.initial_setup.constants import STARTER_FOLDER_NAME
52-
from langflow.services.auth.utils import get_current_active_user
53+
from langflow.services.auth.utils import get_current_active_user, get_optional_user
5354
from langflow.services.authorization import (
5455
FlowAction,
5556
ensure_flow_permission,
@@ -78,7 +79,8 @@
7879
# and FlowVersionError from the flow_version modules.
7980
from langflow.services.database.models.folder.constants import DEFAULT_FOLDER_NAME
8081
from langflow.services.database.models.folder.model import Folder
81-
from langflow.services.deps import get_settings_service, get_storage_service
82+
from langflow.services.database.models.user.model import User
83+
from langflow.services.deps import get_catalog_policy_service, get_settings_service, get_storage_service
8284
from langflow.services.storage.service import StorageService
8385
from langflow.utils.compression import compress_response
8486
from langflow.utils.i18n import translate_flow_notes, translate_starter_flows
@@ -906,26 +908,60 @@ async def download_multiple_file(
906908
_starter_flows_lock = asyncio.Lock()
907909

908910

911+
def _filter_basic_examples_by_catalog_policy(
912+
flows: list[FlowRead],
913+
*,
914+
blocked_template_keys: Collection[str],
915+
) -> list[FlowRead]:
916+
"""Return a request-local view without exact blocked template keys."""
917+
return [flow for flow in flows if flow.name_key not in blocked_template_keys]
918+
919+
909920
@router.get("/basic_examples/", response_model=list[FlowRead], status_code=200)
910921
async def read_basic_examples(
911922
*,
912923
session: DbSession,
913924
request: Request,
925+
user: Annotated[User | None, Depends(get_optional_user)],
926+
include_blocked: bool = False,
914927
):
915928
"""Retrieve a list of basic example flows."""
929+
if include_blocked and (user is None or not user.is_superuser):
930+
raise HTTPException(
931+
status_code=403,
932+
detail="Only superusers can include blocked catalog templates.",
933+
)
934+
935+
catalog_policy_snapshot = get_catalog_policy_service().snapshot
916936
locale = getattr(request.state, "locale", "en")
917937
translated_cache_key = f"starter_flows_{locale}"
918938

919939
# Fast path: translated result already cached for this locale
920940
cached_translated = _starter_flows_translated_cache.get(translated_cache_key)
921941
if cached_translated is not CACHE_MISS:
922-
return compress_response(cached_translated)
942+
visible_flows = (
943+
cached_translated
944+
if include_blocked
945+
else _filter_basic_examples_by_catalog_policy(
946+
cached_translated,
947+
blocked_template_keys=catalog_policy_snapshot.blocked_template_keys,
948+
)
949+
)
950+
return compress_response(visible_flows)
923951

924952
async with _starter_flows_lock:
925953
# Double-check inside lock to prevent thundering herd
926954
cached_translated = _starter_flows_translated_cache.get(translated_cache_key)
927955
if cached_translated is not CACHE_MISS:
928-
return compress_response(cached_translated)
956+
visible_flows = (
957+
cached_translated
958+
if include_blocked
959+
else _filter_basic_examples_by_catalog_policy(
960+
cached_translated,
961+
blocked_template_keys=catalog_policy_snapshot.blocked_template_keys,
962+
)
963+
)
964+
return compress_response(visible_flows)
929965

930966
# Ensure raw DB data is cached
931967
cached_flow_reads = _starter_flows_cache.get("starter_flows")
@@ -967,7 +1003,15 @@ async def read_basic_examples(
9671003

9681004
_starter_flows_translated_cache.set(translated_cache_key, result)
9691005

970-
return compress_response(result)
1006+
visible_flows = (
1007+
result
1008+
if include_blocked
1009+
else _filter_basic_examples_by_catalog_policy(
1010+
result,
1011+
blocked_template_keys=catalog_policy_snapshot.blocked_template_keys,
1012+
)
1013+
)
1014+
return compress_response(visible_flows)
9711015

9721016

9731017
@router.post("/expand/", status_code=200, dependencies=[Depends(get_current_active_user)], include_in_schema=False)

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

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
from typing import Any
22

3-
from fastapi import APIRouter, Depends, HTTPException, Request
3+
from fastapi import APIRouter, HTTPException, Request
44
from pydantic import BaseModel
55

6-
from langflow.services.auth.utils import get_current_active_user
6+
from langflow.api.utils import CurrentActiveUser
7+
from langflow.services.deps import get_catalog_policy_service
78

89
router = APIRouter(prefix="/starter-projects", tags=["Flows"])
910

@@ -37,16 +38,26 @@ class GraphDumpResponse(BaseModel):
3738
data: GraphData
3839
is_component: bool | None = None
3940
name: str | None = None
41+
name_key: str
4042
description: str | None = None
4143
endpoint_name: str | None = None
4244

4345

44-
@router.get("/", dependencies=[Depends(get_current_active_user)], status_code=200)
45-
async def get_starter_projects(request: Request) -> list[GraphDumpResponse]:
46+
@router.get("/", status_code=200)
47+
async def get_starter_projects(
48+
request: Request,
49+
current_user: CurrentActiveUser,
50+
*,
51+
include_blocked: bool = False,
52+
) -> list[GraphDumpResponse]:
4653
"""Get a list of starter projects."""
4754
from langflow.initial_setup.load import get_starter_projects_dump
4855
from langflow.utils.i18n import translate_flow_notes
4956

57+
if include_blocked and not current_user.is_superuser:
58+
raise HTTPException(status_code=403, detail="Only superusers can include blocked catalog templates.")
59+
60+
catalog_policy_snapshot = get_catalog_policy_service().snapshot
5061
locale = getattr(request.state, "locale", "en")
5162

5263
try:
@@ -56,6 +67,12 @@ async def get_starter_projects(request: Request) -> list[GraphDumpResponse]:
5667
# Convert TypedDict GraphDump to Pydantic GraphDumpResponse
5768
results = []
5869
for item in raw_data:
70+
name_key = item.get("name_key")
71+
if not isinstance(name_key, str):
72+
continue
73+
if not include_blocked and catalog_policy_snapshot.is_template_blocked(name_key):
74+
continue
75+
5976
nodes = item.get("data", {}).get("nodes", [])
6077
translated_nodes = translate_flow_notes(nodes, locale)
6178

@@ -71,6 +88,7 @@ async def get_starter_projects(request: Request) -> list[GraphDumpResponse]:
7188
data=graph_data,
7289
is_component=item.get("is_component"),
7390
name=item.get("name"),
91+
name_key=name_key,
7492
description=item.get("description"),
7593
endpoint_name=item.get("endpoint_name"),
7694
)

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
from collections.abc import Callable
22
from typing import Any
33

4+
from langflow.utils.i18n_keys import safe_flow_key
5+
46
from .starter_projects import (
57
basic_prompting_graph,
68
blog_writer_graph,
@@ -56,5 +58,9 @@ def get_starter_projects_graphs():
5658

5759
def get_starter_projects_dump():
5860
return [
59-
build_graph().dump(name=name, description=description) for build_graph, name, description in STARTER_PROJECTS
61+
{
62+
**build_graph().dump(name=name, description=description),
63+
"name_key": safe_flow_key(name),
64+
}
65+
for build_graph, name, description in STARTER_PROJECTS
6066
]

src/backend/tests/unit/api/v1/test_flows.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -568,6 +568,71 @@ async def test_read_basic_examples(client: AsyncClient, logged_in_headers):
568568
assert response.status_code == status.HTTP_200_OK
569569
assert isinstance(result, list), "The result must be a list"
570570
assert len(result) > 0, "The result must have at least one flow"
571+
assert all(item["name_key"] for item in result)
572+
573+
574+
async def test_read_basic_examples_catalog_policy_preserves_public_cache_and_unblocks(
575+
client: AsyncClient,
576+
monkeypatch,
577+
):
578+
from langflow.api.v1 import flows
579+
from lfx.services.catalog_policy import CatalogPolicySnapshot
580+
581+
class MutableCatalogPolicyService:
582+
snapshot = CatalogPolicySnapshot(blocked_template_keys={"basic_prompting"})
583+
584+
service = MutableCatalogPolicyService()
585+
monkeypatch.setattr(flows, "get_catalog_policy_service", lambda: service)
586+
flows._starter_flows_cache.clear()
587+
flows._starter_flows_translated_cache.clear()
588+
589+
blocked_response = await client.get("api/v1/flows/basic_examples/")
590+
assert blocked_response.status_code == status.HTTP_200_OK, blocked_response.text
591+
blocked_keys = {flow["name_key"] for flow in blocked_response.json()}
592+
assert "basic_prompting" not in blocked_keys
593+
594+
service.snapshot = CatalogPolicySnapshot()
595+
unblocked_response = await client.get("api/v1/flows/basic_examples/")
596+
assert unblocked_response.status_code == status.HTTP_200_OK, unblocked_response.text
597+
unblocked_keys = {flow["name_key"] for flow in unblocked_response.json()}
598+
assert "basic_prompting" in unblocked_keys
599+
600+
601+
async def test_read_basic_examples_include_blocked_requires_superuser(
602+
client: AsyncClient,
603+
logged_in_headers,
604+
):
605+
anonymous_response = await client.get("api/v1/flows/basic_examples/?include_blocked=true")
606+
assert anonymous_response.status_code == status.HTTP_403_FORBIDDEN
607+
608+
denied_response = await client.get(
609+
"api/v1/flows/basic_examples/?include_blocked=true",
610+
headers=logged_in_headers,
611+
)
612+
assert denied_response.status_code == status.HTTP_403_FORBIDDEN
613+
614+
615+
async def test_read_basic_examples_superuser_can_include_blocked(
616+
client: AsyncClient,
617+
logged_in_headers_super_user,
618+
monkeypatch,
619+
):
620+
from langflow.api.v1 import flows
621+
from lfx.services.catalog_policy import CatalogPolicySnapshot
622+
623+
service = type(
624+
"CatalogPolicyService",
625+
(),
626+
{"snapshot": CatalogPolicySnapshot(blocked_template_keys={"basic_prompting"})},
627+
)()
628+
monkeypatch.setattr(flows, "get_catalog_policy_service", lambda: service)
629+
630+
override_response = await client.get(
631+
"api/v1/flows/basic_examples/?include_blocked=true",
632+
headers=logged_in_headers_super_user,
633+
)
634+
assert override_response.status_code == status.HTTP_200_OK, override_response.text
635+
assert "basic_prompting" in {flow["name_key"] for flow in override_response.json()}
571636

572637

573638
async def test_read_flows_user_isolation(client: AsyncClient, logged_in_headers, active_user):

src/backend/tests/unit/api/v1/test_starter_projects.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from fastapi import status
44
from httpx import AsyncClient
5+
from lfx.services.catalog_policy import CatalogPolicySnapshot
56

67

78
async def test_get_starter_projects(client: AsyncClient, logged_in_headers):
@@ -29,6 +30,14 @@ async def test_get_starter_projects(client: AsyncClient, logged_in_headers):
2930
"Vector Store RAG": "Load your data for chat context with Retrieval Augmented Generation.",
3031
}
3132

33+
EXPECTED_STARTER_PROJECT_KEYS = {
34+
"basic_prompting",
35+
"blog_writer",
36+
"document_q_a",
37+
"memory_chatbot",
38+
"vector_store_rag",
39+
}
40+
3241

3342
async def test_get_starter_projects_expose_canonical_metadata(client: AsyncClient, logged_in_headers):
3443
"""Each starter project must expose its canonical name and description.
@@ -46,6 +55,7 @@ async def test_get_starter_projects_expose_canonical_metadata(client: AsyncClien
4655

4756
names = [project["name"] for project in result]
4857
assert len(set(names)) == len(names), f"Starter project names must be unique, got: {names}"
58+
assert {project["name_key"] for project in result} == EXPECTED_STARTER_PROJECT_KEYS
4959

5060
actual = {project["name"]: project["description"] for project in result}
5161
assert actual == EXPECTED_STARTER_PROJECTS
@@ -58,6 +68,64 @@ async def test_get_starter_projects_expose_canonical_metadata(client: AsyncClien
5868
)
5969

6070

71+
async def test_starter_projects_catalog_policy_is_request_local_and_unblocks_without_restart(
72+
client: AsyncClient,
73+
logged_in_headers,
74+
monkeypatch,
75+
):
76+
from langflow.api.v1 import starter_projects
77+
78+
class MutableCatalogPolicyService:
79+
snapshot = CatalogPolicySnapshot(blocked_template_keys={"basic_prompting"})
80+
81+
service = MutableCatalogPolicyService()
82+
monkeypatch.setattr(starter_projects, "get_catalog_policy_service", lambda: service)
83+
84+
blocked_response = await client.get("api/v1/starter-projects/", headers=logged_in_headers)
85+
assert blocked_response.status_code == status.HTTP_200_OK, blocked_response.text
86+
assert {project["name_key"] for project in blocked_response.json()} == (
87+
EXPECTED_STARTER_PROJECT_KEYS - {"basic_prompting"}
88+
)
89+
90+
service.snapshot = CatalogPolicySnapshot()
91+
unblocked_response = await client.get("api/v1/starter-projects/", headers=logged_in_headers)
92+
assert unblocked_response.status_code == status.HTTP_200_OK, unblocked_response.text
93+
assert {project["name_key"] for project in unblocked_response.json()} == EXPECTED_STARTER_PROJECT_KEYS
94+
95+
96+
async def test_starter_projects_include_blocked_requires_superuser(
97+
client: AsyncClient,
98+
logged_in_headers,
99+
):
100+
denied_response = await client.get(
101+
"api/v1/starter-projects/?include_blocked=true",
102+
headers=logged_in_headers,
103+
)
104+
assert denied_response.status_code == status.HTTP_403_FORBIDDEN
105+
106+
107+
async def test_starter_projects_superuser_can_include_blocked(
108+
client: AsyncClient,
109+
logged_in_headers_super_user,
110+
monkeypatch,
111+
):
112+
from langflow.api.v1 import starter_projects
113+
114+
service = type(
115+
"CatalogPolicyService",
116+
(),
117+
{"snapshot": CatalogPolicySnapshot(blocked_template_keys={"basic_prompting"})},
118+
)()
119+
monkeypatch.setattr(starter_projects, "get_catalog_policy_service", lambda: service)
120+
121+
override_response = await client.get(
122+
"api/v1/starter-projects/?include_blocked=true",
123+
headers=logged_in_headers_super_user,
124+
)
125+
assert override_response.status_code == status.HTTP_200_OK, override_response.text
126+
assert {project["name_key"] for project in override_response.json()} == EXPECTED_STARTER_PROJECT_KEYS
127+
128+
61129
def test_starter_projects_keep_optional_crewai_exports_lazy():
62130
from langflow.initial_setup import starter_projects
63131

0 commit comments

Comments
 (0)