From f32b19c1f67f3e5b3e98172e439067663fb09668 Mon Sep 17 00:00:00 2001 From: Classic298 <27028174+Classic298@users.noreply.github.com> Date: Mon, 27 Jul 2026 05:21:26 +0200 Subject: [PATCH] feat: add {{USER_GROUPS}} and {{USER_GROUP_IDS}} placeholders for custom forwarded headers (#27236) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Custom per-connection headers can now forward the user's groups to upstream backends via two new template placeholders: - {{USER_GROUPS}}: comma-separated group names - {{USER_GROUP_IDS}}: comma-separated group ids The group lookup is async, so get_custom_headers becomes an async wrapper around the sync template substitution (parse_custom_headers) and fetches groups lazily — only when a header value actually references a groups placeholder. The external document loader path runs in a worker thread without an event loop, so Loader.aload prefetches the groups before offloading and passes them through to ExternalDocumentLoader. Claude-Session: https://claude.ai/code/session_01EbBEfTyu8fFJmC13rnQthT Co-authored-by: Claude --- .../retrieval/loaders/external_document.py | 6 ++-- backend/open_webui/retrieval/loaders/main.py | 10 ++++++ backend/open_webui/routers/configs.py | 4 +-- backend/open_webui/routers/ollama.py | 2 +- backend/open_webui/routers/openai.py | 2 +- backend/open_webui/utils/headers.py | 34 ++++++++++++++++++- backend/open_webui/utils/tools.py | 2 +- 7 files changed, 52 insertions(+), 8 deletions(-) diff --git a/backend/open_webui/retrieval/loaders/external_document.py b/backend/open_webui/retrieval/loaders/external_document.py index 2dd70dbd4b..c199fabcef 100644 --- a/backend/open_webui/retrieval/loaders/external_document.py +++ b/backend/open_webui/retrieval/loaders/external_document.py @@ -6,7 +6,7 @@ from urllib.parse import quote import requests from langchain_core.document_loaders import BaseLoader from langchain_core.documents import Document -from open_webui.utils.headers import get_custom_headers, include_user_info_headers +from open_webui.utils.headers import include_user_info_headers, parse_custom_headers log = logging.getLogger(__name__) @@ -19,6 +19,7 @@ class ExternalDocumentLoader(BaseLoader): api_key: str, mime_type=None, user=None, + user_groups=None, headers=None, metadata=None, **kwargs, @@ -30,6 +31,7 @@ class ExternalDocumentLoader(BaseLoader): self.mime_type = mime_type self.user = user + self.user_groups = user_groups self.headers = headers self.metadata = metadata @@ -49,7 +51,7 @@ class ExternalDocumentLoader(BaseLoader): except Exception: pass - headers.update(get_custom_headers(self.headers, self.user, self.metadata)) + headers.update(parse_custom_headers(self.headers, self.user, self.metadata, user_groups=self.user_groups)) if self.user is not None: headers = include_user_info_headers(headers, self.user) diff --git a/backend/open_webui/retrieval/loaders/main.py b/backend/open_webui/retrieval/loaders/main.py index 218369fd93..3c8b1f11b2 100644 --- a/backend/open_webui/retrieval/loaders/main.py +++ b/backend/open_webui/retrieval/loaders/main.py @@ -28,6 +28,7 @@ from open_webui.retrieval.loaders.external_document import ExternalDocumentLoade from open_webui.retrieval.loaders.mineru import MinerULoader from open_webui.retrieval.loaders.mistral import MistralLoader from open_webui.retrieval.loaders.paddleocr_vl import PADDLEOCR_VL_SUPPORTED_EXTENSIONS, PaddleOCRVLLoader +from open_webui.utils.headers import get_user_groups_for_custom_headers logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL) log = logging.getLogger(__name__) @@ -246,6 +247,7 @@ class Loader: def __init__(self, engine: str = '', **kwargs): self.engine = engine self.user = kwargs.get('user', None) + self.user_groups = kwargs.get('user_groups', None) self.metadata = kwargs.get('metadata', {}) self.kwargs = kwargs @@ -264,6 +266,13 @@ class Loader: loop for the entire parse — minutes for large PDFs. This offloads the work to a worker thread so the loop stays responsive. """ + # Group lookup is async-only, so it must happen before `load` + # is offloaded to a thread without a running event loop. + if self.engine == 'external' and self.user_groups is None: + self.user_groups = await get_user_groups_for_custom_headers( + self.kwargs.get('EXTERNAL_DOCUMENT_LOADER_HEADERS'), self.user + ) + return await asyncio.to_thread(self.load, filename, file_content_type, file_path) def _is_text_file(self, file_ext: str, file_content_type: str) -> bool: @@ -429,6 +438,7 @@ class Loader: api_key=self.kwargs.get('EXTERNAL_DOCUMENT_LOADER_API_KEY'), mime_type=file_content_type, user=self.user, + user_groups=self.user_groups, headers=self.kwargs.get('EXTERNAL_DOCUMENT_LOADER_HEADERS'), metadata={ **self.metadata, diff --git a/backend/open_webui/routers/configs.py b/backend/open_webui/routers/configs.py index d93139396e..c9618dfa88 100644 --- a/backend/open_webui/routers/configs.py +++ b/backend/open_webui/routers/configs.py @@ -614,7 +614,7 @@ async def verify_tool_servers_config(request: Request, form_data: ToolServerConn if form_data.headers and isinstance(form_data.headers, dict): if headers is None: headers = {} - custom_headers = get_custom_headers(form_data.headers, user) + custom_headers = await get_custom_headers(form_data.headers, user) headers.update(custom_headers) await client.connect(form_data.url, headers=headers) @@ -659,7 +659,7 @@ async def verify_tool_servers_config(request: Request, form_data: ToolServerConn if form_data.headers and isinstance(form_data.headers, dict): if headers is None: headers = {} - custom_headers = get_custom_headers(form_data.headers, user) + custom_headers = await get_custom_headers(form_data.headers, user) headers.update(custom_headers) url = get_tool_server_url(form_data.url, form_data.path) diff --git a/backend/open_webui/routers/ollama.py b/backend/open_webui/routers/ollama.py index 09e68d540f..94d54c5299 100644 --- a/backend/open_webui/routers/ollama.py +++ b/backend/open_webui/routers/ollama.py @@ -122,7 +122,7 @@ async def send_request( # Custom per-connection headers last so admin-set headers take precedence. if api_config and api_config.get('headers'): - headers.update(get_custom_headers(api_config['headers'], user, metadata, request=request)) + headers.update(await get_custom_headers(api_config['headers'], user, metadata, request=request)) r = await session.request( method, diff --git a/backend/open_webui/routers/openai.py b/backend/open_webui/routers/openai.py index d331f9f690..01d4a31a34 100644 --- a/backend/open_webui/routers/openai.py +++ b/backend/open_webui/routers/openai.py @@ -211,7 +211,7 @@ async def get_headers_and_cookies( headers['Authorization'] = f'Bearer {token}' if config.get('headers') and isinstance(config.get('headers'), dict): - custom_headers = get_custom_headers(config.get('headers'), user, metadata, request=request) + custom_headers = await get_custom_headers(config.get('headers'), user, metadata, request=request) headers.update(custom_headers) return headers, cookies diff --git a/backend/open_webui/utils/headers.py b/backend/open_webui/utils/headers.py index f7b7297083..a847be4243 100644 --- a/backend/open_webui/utils/headers.py +++ b/backend/open_webui/utils/headers.py @@ -13,9 +13,12 @@ from open_webui.env import ( FORWARD_USER_INFO_HEADER_USER_NAME, FORWARD_USER_INFO_HEADER_USER_ROLE, ) +from open_webui.models.groups import Groups log = logging.getLogger(__name__) +USER_GROUPS_PLACEHOLDERS = ('{{USER_GROUPS}}', '{{USER_GROUP_IDS}}') + def _mint_forward_user_jwt(user: Any) -> str: now = int(time.time()) @@ -59,7 +62,34 @@ def include_user_info_headers(headers: dict, user: Optional[Any] = None) -> dict } -def get_custom_headers(custom_headers: dict, user=None, metadata: dict = None, request=None) -> dict: +def custom_headers_require_user_groups(custom_headers: Optional[dict]) -> bool: + if not custom_headers or not isinstance(custom_headers, dict): + return False + return any( + placeholder in str(value) for value in custom_headers.values() for placeholder in USER_GROUPS_PLACEHOLDERS + ) + + +async def get_user_groups_for_custom_headers(custom_headers: Optional[dict], user: Optional[Any] = None) -> Optional[list]: + """Fetch the user's groups only when a header value actually references a groups placeholder.""" + if user is None or not custom_headers_require_user_groups(custom_headers): + return None + + try: + return await Groups.get_groups_by_member_id(user.id) + except Exception: + log.exception('Failed to resolve user groups for custom headers') + return None + + +async def get_custom_headers(custom_headers: dict, user=None, metadata: dict = None, request=None) -> dict: + user_groups = await get_user_groups_for_custom_headers(custom_headers, user) + return parse_custom_headers(custom_headers, user, metadata, request=request, user_groups=user_groups) + + +def parse_custom_headers( + custom_headers: dict, user=None, metadata: dict = None, request=None, user_groups: Optional[list] = None +) -> dict: if not custom_headers or not isinstance(custom_headers, dict): return {} @@ -93,6 +123,8 @@ def get_custom_headers(custom_headers: dict, user=None, metadata: dict = None, r '{{USER_NAME}}': (user.name.strip() if user else '') or '', '{{USER_EMAIL}}': (user.email.strip() if user else '') or '', '{{USER_ROLE}}': (user.role if user else '') or '', + '{{USER_GROUPS}}': ','.join(group.name.strip() for group in user_groups) if user_groups else '', + '{{USER_GROUP_IDS}}': ','.join(group.id for group in user_groups) if user_groups else '', '{{USER_AGENT}}': user_agent, } diff --git a/backend/open_webui/utils/tools.py b/backend/open_webui/utils/tools.py index 19d07a9949..128301d17c 100644 --- a/backend/open_webui/utils/tools.py +++ b/backend/open_webui/utils/tools.py @@ -168,7 +168,7 @@ async def build_tool_server_headers( # Interpolate template vars in custom connection headers connection_headers = connection.get('headers', None) if connection_headers and isinstance(connection_headers, dict): - headers.update(get_custom_headers(connection_headers, user, metadata)) + headers.update(await get_custom_headers(connection_headers, user, metadata)) # Add user info headers if enabled if ENABLE_FORWARD_USER_INFO_HEADERS and user: