Skip to content

fix(proxy): enforce organization boundaries in admin operations#25904

Merged
yuneng-berri merged 7 commits intoBerriAI:litellm_yj_apr17from
stuxf:fix/org-boundary-enforcement
Apr 17, 2026
Merged

fix(proxy): enforce organization boundaries in admin operations#25904
yuneng-berri merged 7 commits intoBerriAI:litellm_yj_apr17from
stuxf:fix/org-boundary-enforcement

Conversation

@stuxf
Copy link
Copy Markdown
Collaborator

@stuxf stuxf commented Apr 16, 2026

Relevant issues

Fixes inconsistent organization boundary checks in team listing and user creation flows.

Reopens #25829 (original base litellm_yj_apr15 was deleted); rebased onto litellm_internal_staging. No content changes.

Pre-Submission checklist

  • I have Added testing in the tests/test_litellm/ directory, Adding at least 1 test is a hard requirement - see details
  • My PR passes all unit tests on make test-unit
  • My PR's scope is as isolated as possible, it only solves 1 specific problem
  • I have requested a Greptile review by commenting @greptileai and received a Confidence Score of at least 4/5 before requesting a maintainer review

Type

🐛 Bug Fix

Changes

1. Validate org admin role against all requested organizations

_user_is_org_admin() previously returned True if the caller was admin of any one of the listed organizations. Now validates against all of them — an org admin of org-A cannot perform operations targeting org-B.

2. Scope team list queries to caller's organizations

_build_team_list_where_conditions() previously dropped the organization filter when a user_id parameter was provided, returning teams across all organizations. Now always applies the org filter for org admins.

_authorize_and_filter_teams() (legacy path) previously fetched teams outside the caller's orgs if the target user was a member. Now only returns teams within the caller's permitted organizations.

stuxf added 2 commits April 16, 2026 21:06
Validate org admin role against all requested organizations instead
of returning on first match. Scope team list queries to the caller's
permitted organizations when filtering by user_id.
Verify that an org admin of org-A cannot operate on org-B, and that
an admin of both orgs can operate on both.
@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 16, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
litellm Ready Ready Preview, Comment Apr 17, 2026 0:45am

Request Review

@codecov
Copy link
Copy Markdown

codecov Bot commented Apr 16, 2026

Codecov Report

❌ Patch coverage is 33.33333% with 4 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
...oxy/management_endpoints/organization_endpoints.py 0.00% 4 Missing ⚠️

📢 Thoughts on this report? Let us know!

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Apr 16, 2026

Greptile Summary

This PR enforces organization boundaries across multiple admin endpoints by fixing six distinct authorization bypass vulnerabilities: (1) _user_is_org_admin changed from "any org" to "all orgs", (2) team list queries now always scope to the org admin's organizations even when user_id is provided, (3) delete_user validates per-target org membership before deletion, (4) /user/update no longer silently creates users for non-PROXY_ADMIN callers, (5) cross-user key modification now requires admin access for non-budget fields, and (6) /organization/member_delete is scoped to org_admin_allowed_routes to prevent unauthorized membership removal.

Confidence Score: 5/5

Safe to merge — all security fixes are correctly implemented and the primary vulnerability paths are covered by tests.

All six authorization bypass fixes are logically correct: the any→all org admin check, batch-fetch org membership before delete_user, silent-create guard, cross-user key update auth, team list org scoping, and the route-level member_delete gate. The only remaining findings are P2: three newly introduced security restrictions (all_users=True, team relocation, org member role protection) lack negative-path tests, but this does not affect correctness or production safety.

tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py — missing negative tests for all_users=True and team relocation security gates.

Important Files Changed

Filename Overview
litellm/proxy/auth/auth_checks_organization.py Fixes _user_is_org_admin to require admin of ALL requested orgs rather than ANY; set comprehension with None-guard is correct.
litellm/proxy/management_endpoints/internal_user_endpoints.py Adds batch org-membership check before delete_user loop (N+1 addressed) and silent-create guard in _update_single_user_helper; logic is correct.
litellm/proxy/management_endpoints/team_endpoints.py Scopes team list to org admin's orgs even when user_id filter is provided; adds destination-org check for team relocation; restricts all_users=True to PROXY_ADMIN.
litellm/proxy/management_endpoints/key_management_endpoints.py Extends key update auth check to all non-budget fields for non-owner callers; preserves owner exemption for non-budget updates.
litellm/proxy/management_endpoints/organization_endpoints.py Adds guard preventing org admins from modifying the per-org role of a global PROXY_ADMIN user.
litellm/proxy/_types.py Adds /organization/member_delete to org_admin_allowed_routes to enforce the same route-level gate as member_add/member_update.
tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py Updates assertion to verify org scoping with user_id filter; formatting refactors; missing negative tests for all_users=True restriction and team relocation guard.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[Incoming admin request] --> B{PROXY_ADMIN?}
    B -- Yes --> Z[Permitted]
    B -- No --> C{Route in org_admin_allowed_routes?}
    C -- No --> DENY1[401 Unauthorized]
    C -- Yes --> D{_user_is_org_admin ALL orgs?}
    D -- No --> DENY2[403 Forbidden]
    D -- Yes --> E{Endpoint-specific target check}
    E --> E1[delete_user: batch-fetch target memberships]
    E --> E2[team list v2: WHERE org IN caller_orgs AND team_id IN user_teams]
    E --> E3[key/update: is_key_owner? No = _check_key_admin_access]
    E --> E4[user/update: user exists? No + not PROXY_ADMIN = 404]
    E --> E5[update_team: org relocate? Must be org-admin of destination]
    E --> E6[org member_update: target is PROXY_ADMIN? Only PROXY_ADMIN may modify]
    E --> E7[bulk_team_member_add: all_users=True? Only PROXY_ADMIN]
    E1 --> Z
    E2 --> Z
    E3 --> Z
    E4 --> Z
    E5 --> Z
    E6 --> Z
    E7 --> Z
Loading

Reviews (4): Last reviewed commit: "fix(proxy): close /organization member_d..." | Re-trigger Greptile

Veria admin-queue finding E3NpkuAd, Audit-B #1. The route-level gate
accepts this call when the caller is PROXY_ADMIN or ORG_ADMIN of any
org named in request_data["organization_id"]/["organizations"]. The
handler processes data.user_ids without cross-checking whether those
users belong to the caller's administered orgs, so an org-admin of
org-A could delete users in org-B via:
  {"user_ids": ["victim_in_org_B"], "organization_id": "org-A"}

Add per-target authorization: org-admins may only delete users whose
entire org membership is within their admin scope; targets with any
org outside scope (or no org at all) require PROXY_ADMIN.

Regression test confirms an ORG_ADMIN call fails with 403 and no
cascade delete_many runs.
Comment thread litellm/proxy/management_endpoints/internal_user_endpoints.py Outdated
Greptile P1 on the /user/delete fix. Per CLAUDE.md 'No N+1 queries',
move the find_many inside the per-user loop to a single batched
fetch with {'user_id': {'in': data.user_ids}} before the loop, then
distribute to a per-user set in memory.
Continuation of Veria E3NpkuAd / Audit-B hardening. All three are the
same anti-pattern PR BerriAI#25904 already addressed for _user_is_org_admin
and /user/delete: route-level gate trusts a caller-supplied scope
field, handler operates on a different scope.

1. /user/update no longer silently creates a user when the target
   email doesn't exist. Pre-fix, an org admin could supply a fresh
   email + caller-chosen budget/models/metadata; the INSERT path
   created the user with no org attachment, bypassing /user/new's
   org/team authorization. Now require PROXY_ADMIN for the create
   branch; return 404 otherwise. Also fixes /user/bulk_update because
   it dispatches through the same _update_single_user_helper.

2. /team/bulk_member_add with all_users=true restricted to PROXY_ADMIN.
   The flag pulls every user in the database into the target team,
   ignoring org scope — any team admin could use it to capture every
   user across every org into their team.

3. /team/update now verifies destination-org admin rights. When the
   request carries an organization_id that differs from the team's
   current org, an org admin of the caller's current org could
   previously relocate the team into any other org (draining their
   resources, or capturing a team they once administered). Require
   PROXY_ADMIN or org-admin of the DESTINATION org for the relocation.

Regression tests for #1 and BerriAI#3; BerriAI#2 covered by the existing bulk_add
suite after the gate addition.
Audit-B BerriAI#2. _check_key_admin_access was gated on max_budget/spend
changes only, which meant a non-admin caller could blanket-rewrite
any OTHER field on any key (key_alias, models, tpm_limit, rpm_limit,
metadata, tags, allowed_routes, guardrails, blocked, duration,
permissions, auto_rotate, access_group_ids, object_permission, …)
as long as they avoided budget/spend. Example attack:

  POST /key/update {
    key: sk-victim-in-org-B,
    models: [],
    blocked: true,
    organization_id: org-A,
  }

The caller is org-admin of org-A, which satisfies the route gate;
the handler then wipes models and blocks the victim's key.

Policy after this fix:
- PROXY_ADMIN: always allowed.
- Key OWNER (matching user_id): allowed for non-budget fields;
  budget/spend changes still require team/org admin.
- Everyone else: must pass _check_key_admin_access (PROXY_ADMIN /
  key-owner / team-admin / org-admin of the key).

Regression test confirms a non-owner INTERNAL_USER cannot rewrite
key_alias/blocked on someone else's key; existing test covers the
owner-can-update-alias case; existing test covers
internal-user-cannot-modify-max-budget.
Audit-B BerriAI#7 and BerriAI#8.

1. /organization/member_delete was not in org_admin_only_routes, so it
   fell through to management_routes/self_managed_routes and let any
   caller that reached the route delete arbitrary org memberships
   without the organization_role_based_access_check that member_add
   and member_update trigger. Adding it to org_admin_only_routes
   applies the same ORG_ADMIN-of-target-org gate.

2. /organization/member_update had no validation that the target user
   was not a global PROXY_ADMIN. An org-admin of any org could alter
   a PROXY_ADMIN user's per-org role. Reject this unless the caller
   is PROXY_ADMIN.
@yuneng-berri yuneng-berri changed the base branch from litellm_internal_staging to litellm_yj_apr17 April 17, 2026 19:13
@yuneng-berri yuneng-berri merged commit 50a6324 into BerriAI:litellm_yj_apr17 Apr 17, 2026
42 of 44 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants