[Infra] Merge daily branch with main#24055
Conversation
Add Vitest + RTL tests for HelpLink, DebugWarningBanner, ExportFormatSelector, ExportTypeSelector, ExportSummary, MetricCard, PolicySelect, ComplexityRouterConfig, RateLimitTypeFormItem, and AgentCardGrid (67 tests total). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
[Test] UI: Add unit tests for 10 untested components
… and indexes Add org admin support to /v2/team/list so org admins can list teams within their organizations instead of getting 401. Also enrich the response with members_count and add missing indexes. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Fix org admin own-query regression: always check org admin status before the standard route check so own-queries see all org teams - Clear user_id when org admin is detected so org scope replaces user-membership scope - Remove dead isinstance(organization_id, list) branch - Remove unused datetime import - Remove orphaned _convert_teams_to_response helper Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- _get_org_admin_org_ids: catch only ValueError (user not found) instead of bare Exception — DB errors now propagate as 500s instead of silently demoting org admins to regular users - _build_team_list_where_conditions: return None (not a sentinel string) when user has no team memberships; list_team_v2 short-circuits to empty response without hitting the DB - Org admin + team_id + user_id: use exact team_id match with org scope instead of OR expansion that effectively ignored the team_id filter - Org admin + user_id (no team_id): OR(org teams, direct memberships) now matches legacy _authorize_and_filter_teams behaviour Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Replace raw find_unique with get_user_object in _build_team_list_where_conditions for cache/metrics consistency - Remove over-complex OR clause for org admin + user_id: when user_id is provided, filter by that user's direct team memberships (same as regular users) since the access control gate already verified the org admin's authority - Preserve caller-supplied organization_id instead of overwriting with org_admin_org_ids - Update test mock to match get_user_object call path Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Document intentional legacy-matching behavior: when user_id is provided to an org admin, no org filter is applied (returns all of that user's teams across all orgs, same as legacy endpoint) - Fix two existing security tests to properly patch user_api_key_cache, proxy_logging_obj, and get_user_object instead of relying on incidental error handling - Add three new org admin test cases: - Org admin sees org-scoped teams (200 with correct where clause) - Org admin rejected when filtering by other org (403) - Org admin with user_id filter returns target user's teams Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
… auth On /key/block, /key/unblock, and /key/update, the request body 'key' field could contaminate the api_key Security dependency, causing the auth layer to authenticate against the target key instead of the caller's bearer token. This returned 401 for a nonexistent body key even when the Authorization header contained a valid master key. Added a guard in user_api_key_auth that re-reads the Authorization header directly from the request, ensuring the header is always the authoritative source for authentication. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
[Feature] /v2/team/list: Add org admin access control, members_count, and indexes
…eader in auth" This reverts commit 2c874a7.
Extract duplicate file preview JSX blocks (responses and chat image previews) into a reusable FilePreviewCard component, reducing ~50 lines of duplicated markup in ChatUI.tsx. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
[Refactor] UI - Playground: Extract FilePreviewCard from ChatUI
list_team_v2 had 51 statements (limit 50). Extract the team-to-response-model conversion loop into a helper function to satisfy ruff PLR0915. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
| ) | ||
| verbose_proxy_logger.debug( | ||
| "list_team_v2: org admin access for user=%s, org_ids=%s, user_id_filter=%s", | ||
| user_api_key_dict.user_id, |
Check failure
Code scanning / CodeQL
Log Injection High
Show autofix suggestion
Hide autofix suggestion
Copilot Autofix
AI 30 days ago
In general, to fix log injection issues, any user-controlled value written to logs should be sanitized to remove or neutralize control characters that can affect log structure (e.g., \r, \n, \t). For plain-text logs, a common approach is to strip or replace line breaks with safe characters or spaces before passing them to the logger. This preserves the semantic content of the value while ensuring each log entry remains on a single line and cannot be spoofed by attackers.
For this specific case, the vulnerable sink is the verbose_proxy_logger.debug call in list_team_v2 that logs user_api_key_dict.user_id. The best minimal fix without changing functionality is to introduce a small local sanitization helper (or just sanitize inline) for user_api_key_dict.user_id before it is logged, replacing any \r and \n characters with empty strings. We will only touch the shown snippet in litellm/proxy/management_endpoints/team_endpoints.py. A clear and localized change is to define a sanitized variable safe_user_id_for_log immediately before the log statement:
safe_user_id_for_log = (
str(user_api_key_dict.user_id).replace("\r", "").replace("\n", "")
if user_api_key_dict.user_id is not None
else None
)
verbose_proxy_logger.debug(
"list_team_v2: org admin access for user=%s, org_ids=%s, user_id_filter=%s",
safe_user_id_for_log,
org_admin_org_ids,
user_id,
)This preserves the log message semantics while ensuring that any newline characters in user_id cannot affect log structure. No new imports or external dependencies are required; we use only built-in string methods. We are careful not to alter the values used for actual authorization logic—only the value passed into the logger is sanitized, so functionality remains unchanged.
| @@ -3456,9 +3456,14 @@ | ||
| "error": "You can only view teams within your organizations." | ||
| }, | ||
| ) | ||
| safe_user_id_for_log = ( | ||
| str(user_api_key_dict.user_id).replace("\r", "").replace("\n", "") | ||
| if user_api_key_dict.user_id is not None | ||
| else None | ||
| ) | ||
| verbose_proxy_logger.debug( | ||
| "list_team_v2: org admin access for user=%s, org_ids=%s, user_id_filter=%s", | ||
| user_api_key_dict.user_id, | ||
| safe_user_id_for_log, | ||
| org_admin_org_ids, | ||
| user_id, | ||
| ) |
| "list_team_v2: org admin access for user=%s, org_ids=%s, user_id_filter=%s", | ||
| user_api_key_dict.user_id, | ||
| org_admin_org_ids, | ||
| user_id, |
Check failure
Code scanning / CodeQL
Log Injection High
Show autofix suggestion
Hide autofix suggestion
Copilot Autofix
AI 30 days ago
To fix the problem, sanitize the user-controlled value before it is written to the log. In this context that means ensuring user_id (and any other directly logged user input) cannot inject line breaks or other control characters that could confuse log parsing. A simple and compatible mitigation for plain-text logs is to strip or replace \r and \n (and optionally other control characters) from the string before logging.
The single best minimally invasive fix here is to introduce a small helper function in this file that takes a string (or None) and returns a sanitized version with \r and \n removed. Then, wrap user_id in this helper when passing it to verbose_proxy_logger.debug at line 3463. This preserves all existing functionality (same value, minus dangerous line breaks) while eliminating the injection vector. The helper can be defined once near the top of the file (after imports) and reused wherever needed.
Concretely:
- In
litellm/proxy/management_endpoints/team_endpoints.py, add a function such as_sanitize_for_logging(value: Optional[str]) -> Optional[str]that removes\rand\nfrom non-Nonestrings. - At the debug logging call around lines 3459–3464, change the third argument from
user_idto_sanitize_for_logging(user_id)so that the logged value is safe. - No new imports are required; we only use basic string operations.
| @@ -22,6 +22,16 @@ | ||
| import litellm | ||
| from litellm._logging import verbose_proxy_logger | ||
| from litellm._uuid import uuid | ||
|
|
||
|
|
||
| def _sanitize_for_logging(value: Optional[str]) -> Optional[str]: | ||
| """ | ||
| Remove newline characters from user-controlled values before logging | ||
| to reduce the risk of log injection. | ||
| """ | ||
| if value is None: | ||
| return None | ||
| return value.replace("\r", "").replace("\n", "") | ||
| from litellm.litellm_core_utils.safe_json_dumps import safe_dumps | ||
| from litellm.proxy._types import ( | ||
| BlockTeamRequest, | ||
| @@ -3460,7 +3470,7 @@ | ||
| "list_team_v2: org admin access for user=%s, org_ids=%s, user_id_filter=%s", | ||
| user_api_key_dict.user_id, | ||
| org_admin_org_ids, | ||
| user_id, | ||
| _sanitize_for_logging(user_id), | ||
| ) | ||
| else: | ||
| # Not an org admin — fall back to standard route check |
Greptile SummaryThis PR merges the Key changes:
Confidence Score: 3/5
|
| Filename | Overview |
|---|---|
| litellm/proxy/management_endpoints/team_endpoints.py | Core change: org admin access added to /v2/team/list, members_count added to list items, and user lookup refactored to get_user_object. Contains a privilege escalation risk when org admin queries with user_id parameter. |
| litellm/types/proxy/management_endpoints/team_endpoints.py | New TeamListItem type extending LiteLLM_TeamTable with a members_count: int = 0 field; TeamListResponse.teams union updated to include it. Backward-compatible change. |
| litellm-proxy-extras/litellm_proxy_extras/migrations/20260318140652_add_index_to_team_table/migration.sql | Adds three indexes (organization_id, team_alias, created_at) to LiteLLM_TeamTable. Clean performance improvement aligned with the new query patterns. |
| schema.prisma | Prisma schema updated with the same three indexes on LiteLLM_TeamTable; mirrors the migration SQL and the proxy-extras schema. |
| tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py | Existing security tests updated to mock get_user_object (no real DB calls); three new org-admin scenario tests added (sees org teams, blocked from other orgs, user_id filter). All tests remain mock-only, appropriate for this directory. |
| ui/litellm-dashboard/src/components/playground/chat_ui/FilePreviewCard.tsx | New reusable component extracted from duplicated inline code in ChatUI.tsx; clean and well-tested. |
| ui/litellm-dashboard/src/components/playground/chat_ui/ChatUI.tsx | Replaced two copies of the file-preview card markup with <FilePreviewCard /> — pure refactor, no logic changes. |
| ui/litellm-dashboard/src/components/playground/chat_ui/FilePreviewCard.test.tsx | Comprehensive unit tests for the new FilePreviewCard component, covering PDF vs image rendering, remove callback, and uppercase .PDF extension handling. |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[GET /v2/team/list] --> B{prisma_client connected?}
B -- No --> Z[500 Error]
B -- Yes --> C{is_proxy_admin?}
C -- Yes --> G[Build where conditions\nno user filter imposed]
C -- No --> D[_get_org_admin_org_ids\ncalls get_user_object for caller]
D --> E{org_admin_org_ids\nreturned?}
E -- Yes --> F{org filter\nprovided?}
F -- org not in admin orgs --> Z2[403 Error]
F -- allowed --> G
E -- No --> H{allowed_route_check\ninside_route?}
H -- Fails --> Z3[401 Error]
H -- Passes --> I[Auto-inject\ncaller user_id]
I --> G
G --> J[_build_team_list_where_conditions]
J --> K{user_id provided?}
K -- Yes --> L[get_user_object for target user\n2nd DB call]
L --> M{user has teams?}
M -- No --> N[Return empty result\nskip DB]
M -- Yes --> O[Filter by user team IDs\nNo org constraint applied ⚠️]
K -- No --> P{org_admin_org_ids?}
P -- Yes --> Q[Filter by admin org IDs]
P -- No --> R[No user/org filter]
O --> S[DB query + paginate]
Q --> S
R --> S
N --> T[Return empty page]
S --> U[_convert_teams_to_response_models\nadds members_count]
U --> V[Return TeamListResponse]
Last reviewed commit: "Merge branch 'main' ..."
|
|
||
| if organization_id: | ||
| where_conditions["organization_id"] = organization_id | ||
| elif org_admin_org_ids is not None and not user_id: | ||
| # Org admin without explicit org or user filter: scope to their orgs. | ||
| # NOTE: when user_id is provided, no org filter is applied — the | ||
| # query returns all teams the target user belongs to across all | ||
| # organisations. This matches the legacy /team/list behaviour in | ||
| # _authorize_and_filter_teams which fetches direct-membership teams |
There was a problem hiding this comment.
Org admin can view any user's teams across all orgs (privilege escalation)
When user_id is provided, the org-admin scope check is deliberately skipped (elif org_admin_org_ids is not None and not user_id). This means an org admin from org_A can pass user_id=<any_user> and retrieve all of that user's teams — even teams that belong to org_B or org_C, organizations the admin has no authority over.
The original /v2/team/list endpoint rejected org admins entirely; this PR grants them broader access than intended. The org-filter should be applied even when user_id is specified, e.g. by intersecting the user's team IDs with teams belonging to org_admin_org_ids.
Example of a more constrained fix:
# When user_id AND org_admin_org_ids are both present, combine the filters
if not user_team_ids:
return None
where_conditions["team_id"] = {"in": user_team_ids}
if org_admin_org_ids is not None:
# Limit to teams in the admin's orgs
where_conditions["organization_id"] = {"in": org_admin_org_ids}| return available_teams_correct_type | ||
|
|
||
|
|
||
| async def _get_org_admin_org_ids( | ||
| user_id: str, | ||
| prisma_client: Any, | ||
| user_api_key_cache: Any, | ||
| proxy_logging_obj: Any, | ||
| ) -> Optional[List[str]]: | ||
| """ | ||
| Return the list of organization IDs where the user is an org admin. | ||
| Returns None if the user is not an org admin of any organization or if | ||
| the user cannot be found. | ||
| """ | ||
| try: | ||
| caller_user = await get_user_object( | ||
| user_id=user_id, | ||
| prisma_client=prisma_client, | ||
| user_api_key_cache=user_api_key_cache, | ||
| user_id_upsert=False, | ||
| proxy_logging_obj=proxy_logging_obj, | ||
| ) | ||
| except ValueError: | ||
| # get_user_object raises ValueError when the user doesn't exist | ||
| return None | ||
|
|
||
| if caller_user is None: | ||
| return None | ||
|
|
||
| org_ids = [ | ||
| m.organization_id | ||
| for m in (caller_user.organization_memberships or []) | ||
| if m.user_role == LitellmUserRoles.ORG_ADMIN.value | ||
| ] | ||
| return org_ids if org_ids else None | ||
|
|
There was a problem hiding this comment.
Extra DB call on every non-admin
/v2/team/list request
_get_org_admin_org_ids invokes get_user_object unconditionally for every non-proxy-admin call to list_team_v2. This adds a database round-trip (or cache lookup) on every request even when the caller is a plain user who will never be an org admin. While list_team_v2 is a management endpoint (not the inference critical path), the lookup is also repeated inside _build_team_list_where_conditions when a user_id filter is provided, so an org-admin query with user_id causes two get_user_object calls.
Consider passing the already-fetched caller user object into _build_team_list_where_conditions to avoid the second lookup, and caching the org-admin status on the UserAPIKeyAuth dict if this endpoint is called frequently.
|
|
||
| mock_prisma_client = Mock() | ||
|
|
||
| # Mock prisma_client to be non-None |
There was a problem hiding this comment.
Whitespace-only leftover in test file
The change to test_list_team_v2_with_invalid_status converted indentation/trailing whitespace in surrounding tests but left one trailing-space line:
mock_prisma_client = Mock()
# Mock prisma_client to be non-NoneThe blank line between mock_prisma_client = Mock() and the comment still has trailing whitespace (the old style). Minor, but inconsistent with the rest of the cleanup in this file.
|
think you should add IF NOT EXISTS in litellm-proxy-extras/litellm_proxy_extras/migrations/20260318140652_add_index_to_team_table/migration.sql @ryan-crabbe @yuneng-jiang |
Relevant issues
Pre-Submission checklist
https://app.circleci.com/pipelines/github/BerriAI/litellm?branch=litellm_yj_march_17_2026
Please complete all items before asking a LiteLLM maintainer to review your PR
tests/test_litellm/directory, Adding at least 1 test is a hard requirement - see detailsmake test-unit@greptileaiand received a Confidence Score of at least 4/5 before requesting a maintainer reviewDelays in PR merge?
If you're seeing a delay in your PR being merged, ping the LiteLLM Team on Slack (#pr-review).
CI (LiteLLM team)
Branch creation CI run
Link:
CI run for the last commit
Link:
Merge / cherry-pick CI run
Links:
Type
🚄 Infrastructure
Changes