Skip to content

Commit 713dc19

Browse files
authored
feat(catalog): expose governance config flag (#14319)
* feat(catalog): expose governance config flag * feat(catalog): filter component palette by policy (#14320) * feat(catalog): filter component palette by policy * 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 4fa9a15 commit 713dc19

15 files changed

Lines changed: 920 additions & 26 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/endpoints.py

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import asyncio
44
import json
55
import time
6-
from collections.abc import AsyncGenerator
6+
from collections.abc import AsyncGenerator, Collection
77
from http import HTTPStatus
88
from typing import TYPE_CHECKING, Annotated, Any
99
from uuid import UUID, uuid4
@@ -80,6 +80,7 @@
8080
from langflow.services.database.models.user.model import User, UserRead
8181
from langflow.services.deps import (
8282
get_auth_service,
83+
get_catalog_policy_service,
8384
get_job_service,
8485
get_memory_base_service,
8586
get_session_service,
@@ -180,22 +181,34 @@ async def parse_input_request_from_body(http_request: Request) -> SimplifiedAPIR
180181

181182

182183
@router.get("/all")
183-
async def get_all(request: Request, current_user: CurrentActiveUser):
184+
async def get_all(request: Request, current_user: CurrentActiveUser, *, include_blocked: bool = False):
184185
"""Retrieve all component types with compression for better performance.
185186
186187
Returns a compressed response containing all available component types,
187188
with display_names translated to the locale indicated by Accept-Language.
188189
"""
190+
if include_blocked and not current_user.is_superuser:
191+
raise HTTPException(
192+
status_code=status.HTTP_403_FORBIDDEN,
193+
detail="Only superusers can include blocked catalog components.",
194+
)
195+
189196
from langflow.interface.components import get_and_cache_all_types_dict
190197
from langflow.utils.i18n import build_component_display_names, translate_component_dict
191198

192199
try:
200+
catalog_policy_snapshot = get_catalog_policy_service().snapshot
193201
all_types_en = await get_and_cache_all_types_dict(settings_service=get_settings_service())
194202
visible_types_en = _filter_component_palette_by_provider_policy(
195203
all_types_en,
196204
user_id=current_user.id,
197205
attributes={"is_superuser": bool(current_user.is_superuser)},
198206
)
207+
if not include_blocked:
208+
visible_types_en = _filter_component_palette_by_catalog_policy(
209+
visible_types_en,
210+
blocked_component_keys=catalog_policy_snapshot.blocked_component_keys,
211+
)
199212

200213
locale = getattr(request.state, "locale", "en")
201214
all_types = translate_component_dict(visible_types_en, locale) if locale != "en" else visible_types_en
@@ -248,6 +261,28 @@ def _filter_component_palette_by_provider_policy(
248261
}
249262

250263

264+
def _filter_component_palette_by_catalog_policy(
265+
all_types: dict[str, dict[str, dict]],
266+
*,
267+
blocked_component_keys: Collection[str],
268+
) -> dict[str, dict[str, dict]]:
269+
"""Return shallow category copies with exact blocked component keys removed.
270+
271+
Catalog policy keys match the inner component-registry keys exactly and
272+
case-sensitively across every category. Category order, component order,
273+
and empty categories are preserved. Component payloads remain shared with
274+
the process-wide cache and are never mutated or deep-copied.
275+
"""
276+
return {
277+
category: {
278+
component_key: component
279+
for component_key, component in components.items()
280+
if component_key not in blocked_component_keys
281+
}
282+
for category, components in all_types.items()
283+
}
284+
285+
251286
def validate_input_and_tweaks(input_request: SimplifiedAPIRequest) -> None:
252287
# If the input_value is not None and the input_type is "chat"
253288
# then we need to check the tweaks if the ChatInput component is present
@@ -1670,14 +1705,27 @@ async def get_config(
16701705
"""
16711706
try:
16721707
settings_service: SettingsService = get_settings_service()
1708+
try:
1709+
catalog_governance_enabled = get_catalog_policy_service().enabled
1710+
except Exception as exc: # noqa: BLE001
1711+
# Catalog governance is explicitly fail-open. A broken custom
1712+
# policy implementation must not break the public config endpoint
1713+
# or expose its internal exception text.
1714+
await logger.aexception("Catalog policy status unavailable; reporting governance disabled", exception=exc)
1715+
catalog_governance_enabled = False
16731716

16741717
if user is None:
16751718
return PublicConfigResponse.from_settings(
16761719
settings_service.settings,
16771720
settings_service.auth_settings,
1721+
catalog_governance_enabled=catalog_governance_enabled,
16781722
)
16791723

1680-
return ConfigResponse.from_settings(settings_service.settings, settings_service.auth_settings)
1724+
return ConfigResponse.from_settings(
1725+
settings_service.settings,
1726+
settings_service.auth_settings,
1727+
catalog_governance_enabled=catalog_governance_enabled,
1728+
)
16811729

16821730
except Exception as exc:
16831731
raise HTTPException(status_code=500, detail=str(exc)) from exc

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/schemas/__init__.py

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,9 @@ class BaseConfigResponse(BaseModel):
394394
# Mirrors ``LANGFLOW_AUTHZ_ENABLED``. EE/custom frontends gate the Access
395395
# Control settings entry on this flag; OSS UI ignores it until wired.
396396
authz_enabled: bool = False
397+
# Signals that at least one component or template is governed without
398+
# exposing the policy contents through the public config response.
399+
catalog_governance_enabled: bool = False
397400

398401

399402
class PublicConfigResponse(BaseConfigResponse):
@@ -407,12 +410,19 @@ class PublicConfigResponse(BaseConfigResponse):
407410
allow_custom_components: bool
408411

409412
@classmethod
410-
def from_settings(cls, settings: Settings, auth_settings) -> "PublicConfigResponse":
413+
def from_settings(
414+
cls,
415+
settings: Settings,
416+
auth_settings,
417+
*,
418+
catalog_governance_enabled: bool = False,
419+
) -> "PublicConfigResponse":
411420
"""Create a PublicConfigResponse instance using values from a Settings object.
412421
413422
Parameters:
414423
settings (Settings): The Settings object containing configuration values.
415424
auth_settings: Auth settings (for ``authz_enabled``).
425+
catalog_governance_enabled: Whether any catalog policy currently restricts resources.
416426
417427
Returns:
418428
PublicConfigResponse: An instance populated with public-safe configuration values.
@@ -427,6 +437,7 @@ def from_settings(cls, settings: Settings, auth_settings) -> "PublicConfigRespon
427437
enable_extension_reload=settings.enable_extension_reload,
428438
allow_custom_components=settings.allow_custom_components,
429439
authz_enabled=bool(getattr(auth_settings, "AUTHZ_ENABLED", False)),
440+
catalog_governance_enabled=catalog_governance_enabled,
430441
)
431442

432443

@@ -465,12 +476,19 @@ class ConfigResponse(BaseConfigResponse):
465476
agentic_experience: bool = True
466477

467478
@classmethod
468-
def from_settings(cls, settings: Settings, auth_settings) -> "ConfigResponse":
479+
def from_settings(
480+
cls,
481+
settings: Settings,
482+
auth_settings,
483+
*,
484+
catalog_governance_enabled: bool = False,
485+
) -> "ConfigResponse":
469486
"""Create a ConfigResponse instance using values from a Settings object and AuthSettings.
470487
471488
Parameters:
472489
settings (Settings): The Settings object containing configuration values.
473490
auth_settings: The AuthSettings object containing authentication configuration values.
491+
catalog_governance_enabled: Whether any catalog policy currently restricts resources.
474492
475493
Returns:
476494
ConfigResponse: An instance populated with configuration and feature flag values.
@@ -498,6 +516,7 @@ def from_settings(cls, settings: Settings, auth_settings) -> "ConfigResponse":
498516
hide_getting_started_progress=settings.hide_getting_started_progress,
499517
allow_custom_components=settings.allow_custom_components,
500518
authz_enabled=bool(getattr(auth_settings, "AUTHZ_ENABLED", False)),
519+
catalog_governance_enabled=catalog_governance_enabled,
501520
embedded_mode=settings.embedded_mode,
502521
hide_logout_button=settings.hide_logout_button or settings.embedded_mode,
503522
hide_new_project_button=settings.hide_new_project_button or settings.embedded_mode,

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
]

0 commit comments

Comments
 (0)