Skip to content

Commit dc68bea

Browse files
committed
feat(catalog): add policy service and admin API (#14318)
* feat(catalog): add policy service and admin API * 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 5e757c1 commit dc68bea

37 files changed

Lines changed: 2101 additions & 26 deletions

src/backend/base/langflow/api/router.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
authz_roles_router,
1515
authz_shares_router,
1616
authz_teams_router,
17+
catalog_policy_router,
1718
chat_router,
1819
endpoints_router,
1920
extensions_router,
@@ -101,6 +102,7 @@ def include_deployment_router(target_router: APIRouter) -> None:
101102
router_v1.include_router(authz_role_assignments_router)
102103
router_v1.include_router(authz_teams_router)
103104
router_v1.include_router(authz_me_router)
105+
router_v1.include_router(catalog_policy_router)
104106

105107

106108
# Extension reload is Mode A (local-dev / pip-installed) only. The route is

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from langflow.api.v1.authz_roles import router as authz_roles_router
77
from langflow.api.v1.authz_shares import router as authz_shares_router
88
from langflow.api.v1.authz_teams import router as authz_teams_router
9+
from langflow.api.v1.catalog_policy import router as catalog_policy_router
910
from langflow.api.v1.chat import router as chat_router
1011
from langflow.api.v1.endpoints import router as endpoints_router
1112
from langflow.api.v1.extensions import router as extensions_router
@@ -42,6 +43,7 @@
4243
"authz_roles_router",
4344
"authz_shares_router",
4445
"authz_teams_router",
46+
"catalog_policy_router",
4547
"chat_router",
4648
"endpoints_router",
4749
"extensions_router",
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
"""Superuser administration API for global catalog block policy."""
2+
3+
from __future__ import annotations
4+
5+
from typing import Annotated, Literal
6+
from uuid import UUID
7+
8+
from fastapi import APIRouter, Depends
9+
10+
from langflow.api.v1.schemas.catalog_policy import CatalogPolicyBlockedSet
11+
from langflow.services.auth.utils import get_current_active_superuser
12+
from langflow.services.authorization.audit import audit_decision
13+
from langflow.services.database.models.user.model import User
14+
from langflow.services.deps import get_catalog_policy_service
15+
16+
router = APIRouter(prefix="/catalog-policy", tags=["Catalog Policy"])
17+
18+
19+
def _response(blocked: frozenset[str]) -> CatalogPolicyBlockedSet:
20+
return CatalogPolicyBlockedSet(blocked=sorted(blocked))
21+
22+
23+
async def _audit_update(
24+
*,
25+
user_id: UUID,
26+
resource_kind: Literal["component", "template"],
27+
added: frozenset[str],
28+
removed: frozenset[str],
29+
) -> None:
30+
"""Emit one post-commit audit event per changed catalog key."""
31+
for key in sorted(added):
32+
await audit_decision(
33+
user_id=user_id,
34+
action="catalog:block",
35+
obj=f"{resource_kind}:{key}",
36+
result="allow",
37+
details={
38+
"resource_kind": resource_kind,
39+
"resource_key": key,
40+
},
41+
)
42+
for key in sorted(removed):
43+
await audit_decision(
44+
user_id=user_id,
45+
action="catalog:unblock",
46+
obj=f"{resource_kind}:{key}",
47+
result="allow",
48+
details={
49+
"resource_kind": resource_kind,
50+
"resource_key": key,
51+
},
52+
)
53+
54+
55+
@router.get("/components", response_model=CatalogPolicyBlockedSet)
56+
async def get_component_policy(
57+
_admin: Annotated[User, Depends(get_current_active_superuser)],
58+
) -> CatalogPolicyBlockedSet:
59+
"""Return the complete global component block set."""
60+
service = get_catalog_policy_service()
61+
return _response(service.snapshot.blocked_component_keys)
62+
63+
64+
@router.put("/components", response_model=CatalogPolicyBlockedSet)
65+
async def replace_component_policy(
66+
payload: CatalogPolicyBlockedSet,
67+
admin: Annotated[User, Depends(get_current_active_superuser)],
68+
) -> CatalogPolicyBlockedSet:
69+
"""Replace the complete global component block set."""
70+
service = get_catalog_policy_service()
71+
update = await service.replace_blocked_component_keys(
72+
payload.blocked,
73+
actor_user_id=admin.id,
74+
)
75+
await _audit_update(
76+
user_id=admin.id,
77+
resource_kind="component",
78+
added=update.added,
79+
removed=update.removed,
80+
)
81+
return _response(update.snapshot.blocked_component_keys)
82+
83+
84+
@router.get("/templates", response_model=CatalogPolicyBlockedSet)
85+
async def get_template_policy(
86+
_admin: Annotated[User, Depends(get_current_active_superuser)],
87+
) -> CatalogPolicyBlockedSet:
88+
"""Return the complete global template block set."""
89+
service = get_catalog_policy_service()
90+
return _response(service.snapshot.blocked_template_keys)
91+
92+
93+
@router.put("/templates", response_model=CatalogPolicyBlockedSet)
94+
async def replace_template_policy(
95+
payload: CatalogPolicyBlockedSet,
96+
admin: Annotated[User, Depends(get_current_active_superuser)],
97+
) -> CatalogPolicyBlockedSet:
98+
"""Replace the complete global template block set."""
99+
service = get_catalog_policy_service()
100+
update = await service.replace_blocked_template_keys(
101+
payload.blocked,
102+
actor_user_id=admin.id,
103+
)
104+
await _audit_update(
105+
user_id=admin.id,
106+
resource_kind="template",
107+
added=update.added,
108+
removed=update.removed,
109+
)
110+
return _response(update.snapshot.blocked_template_keys)
111+
112+
113+
__all__ = ["router"]

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)

0 commit comments

Comments
 (0)