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:
Classic298
2026-07-26 23:21:26 -04:00
committed by GitHub
co-authored by Claude
parent 3cd72ee6a8
commit f32b19c1f6
7 changed files with 52 additions and 8 deletions
+33 -1
View File
@@ -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,
}