mirror of
https://github.com/open-webui/open-webui.git
synced 2026-08-13 01:02:25 -06:00
feat: add {{USER_GROUPS}} and {{USER_GROUP_IDS}} placeholders for custom forwarded headers (#27236)
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user