litellm - 💡(How to fix) Fix [Bug]: org_admin user receives 401 on POST /team/update despite being authorized [1 participants]

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…
GitHub stats
BerriAI/litellm#27294Fetched 2026-05-07 03:33:15
View on GitHub
Comments
0
Participants
1
Timeline
2
Reactions
0
Participants
Timeline (top)
labeled ×2

Error Message

{
  "error": {
    "message": "Authentication Error, Only proxy admin can be used to generate, delete, update info for new keys/users/teams. Route=/team/update. Your role=internal_user. Your user_id=...",
    "type": "auth_error",
    "code": 401
  }
}

Root Cause

The root cause is in the proxy auth pipeline: non_proxy_admin_allowed_routes_check rejects the request before the handler runs because the org-admin route-gate helper requires the request body to contain organization_id, but UpdateTeamRequest makes that field optional and the typical caller passes only team_id.

Code Example

def _user_is_org_admin(
    request_data: dict,
    user_object: Optional[LiteLLM_UserTable] = None,
) -> bool:
    ...
    candidate_org_ids: List[str] = []
    singular = request_data.get("organization_id", None)
    if singular is not None:
        candidate_org_ids.append(singular)
    orgs_list = request_data.get("organizations", None)
    if isinstance(orgs_list, list):
        candidate_org_ids.extend(orgs_list)

    if not candidate_org_ids:
        return False
    ...

---

elif _user_is_org_admin(
    request_data=request_data, user_object=user_obj
) and RouteChecks.check_route_access(
    route=route, allowed_routes=LiteLLMRoutes.org_admin_allowed_routes.value
):
    pass

---

else:
    RouteChecks._raise_admin_only_route_exception(
        user_obj=user_obj, route=route
    )

---

async def _verify_team_access(
    team_obj: LiteLLM_TeamTable,
    user_api_key_dict: UserAPIKeyAuth,
) -> None:
    if user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN:
        return
    if _is_user_team_admin(user_api_key_dict=user_api_key_dict, team_obj=team_obj):
        return
    if await _is_user_org_admin_for_team(
        user_api_key_dict=user_api_key_dict, team_obj=team_obj
    ):
        return
    raise HTTPException(
        status_code=status.HTTP_403_FORBIDDEN,
        detail="You do not have access to this team",
    )

---

self_managed_routes = [
    "/team/member_add",
    "/team/member_delete",
    "/team/member_update",
    "/team/permissions_list",
    "/team/permissions_update",
    "/team/daily/activity",
    "/team/{team_id}/members/me",
    ...
]

---

{
  "error": {
    "message": "Authentication Error, Only proxy admin can be used to generate, delete, update info for new keys/users/teams. Route=/team/update. Your role=internal_user. Your user_id=...",
    "type": "auth_error",
    "code": 401
  }
}

---

curl -X POST http://localhost:4000/team/update \
     -H "Authorization: Bearer <u1-key>" \
     -H "Content-Type: application/json" \
     -d '{"team_id": "<t1-id>", "max_budget": 10}'

---

self_managed_routes = [
    "/team/member_add",
    "/team/member_delete",
    "/team/member_update",
    "/team/permissions_list",
    "/team/permissions_update",
    "/team/daily/activity",
    "/team/{team_id}/members/me",
    # /team/update is also self-managed: its handler calls
    # _verify_team_access for per-team scope (proxy admin / team admin /
    # org admin of the team's organization).
    "/team/update",
    ...
]

---
RAW_BUFFERClick to expand / collapse

Check for existing issues

  • I have searched the existing issues and checked that my issue is not a duplicate.

What happened?

An org_admin user (configured via LiteLLM_OrganizationMembership with user_role = "org_admin") receives 401 Authentication Error when calling POST /team/update, even though /team/update is listed in LiteLLMRoutes.org_admin_allowed_routes and the endpoint's own authorization check (_verify_team_access) correctly handles org admins.

The root cause is in the proxy auth pipeline: non_proxy_admin_allowed_routes_check rejects the request before the handler runs because the org-admin route-gate helper requires the request body to contain organization_id, but UpdateTeamRequest makes that field optional and the typical caller passes only team_id.

The same gap likely affects /team/delete, /team/block, and /team/unblock, which also rely on _verify_team_access and are not in self_managed_routes.

Root Cause Analysis

The bug: _user_is_org_admin returns False when no organization_id is in the request body

In litellm/proxy/auth/auth_checks_organization.py:142-180:

def _user_is_org_admin(
    request_data: dict,
    user_object: Optional[LiteLLM_UserTable] = None,
) -> bool:
    ...
    candidate_org_ids: List[str] = []
    singular = request_data.get("organization_id", None)
    if singular is not None:
        candidate_org_ids.append(singular)
    orgs_list = request_data.get("organizations", None)
    if isinstance(orgs_list, list):
        candidate_org_ids.extend(orgs_list)

    if not candidate_org_ids:
        return False
    ...

UpdateTeamRequest (litellm/proxy/_types.py:1850-1852) declares team_id as required and organization_id as optional, and the documented examples (team_endpoints.py:1554-1573) never include it — the team's organization is implicit. So the request body has no organization_idcandidate_org_ids is empty → returns False.

Where this rejects the request

In litellm/proxy/auth/route_checks.py:258-263, non_proxy_admin_allowed_routes_check calls:

elif _user_is_org_admin(
    request_data=request_data, user_object=user_obj
) and RouteChecks.check_route_access(
    route=route, allowed_routes=LiteLLMRoutes.org_admin_allowed_routes.value
):
    pass

With _user_is_org_admin returning False, the check falls through. /team/update is in management_routes (_types.py:560) but NOT in internal_user_routes (so the INTERNAL_USER branch at route_checks.py:251-256 doesn't match) and NOT in self_managed_routes (_types.py:682-710 — so the self-managed branch at route_checks.py:272-275 doesn't match either).

Execution reaches route_checks.py:303-305:

else:
    RouteChecks._raise_admin_only_route_exception(
        user_obj=user_obj, route=route
    )

_raise_admin_only_route_exception (route_checks.py:166-189) raises an exception with the message "Only proxy admin can be used to generate, delete, update info for new keys/users/teams. Route=/team/update. ...", which the auth wrapper surfaces as 401.

The correct authorization gate is in the handler — but never runs

update_team (litellm/proxy/management_endpoints/team_endpoints.py:1498-1634) calls _verify_team_access at line 1631. That helper is at team_endpoints.py:130-158:

async def _verify_team_access(
    team_obj: LiteLLM_TeamTable,
    user_api_key_dict: UserAPIKeyAuth,
) -> None:
    if user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN:
        return
    if _is_user_team_admin(user_api_key_dict=user_api_key_dict, team_obj=team_obj):
        return
    if await _is_user_org_admin_for_team(
        user_api_key_dict=user_api_key_dict, team_obj=team_obj
    ):
        return
    raise HTTPException(
        status_code=status.HTTP_403_FORBIDDEN,
        detail="You do not have access to this team",
    )

_is_user_org_admin_for_team (management_endpoints/common_utils.py:68-105) does exactly the right thing: it looks up the team's organization_id, fetches the caller's LiteLLM_OrganizationMembership rows, and returns True if the caller is ORG_ADMIN for that team's org. But the route gate rejects the request before this code path is reached.

The pattern that should have been used: self_managed_routes

Sibling team routes that work correctly are in self_managed_routes (litellm/proxy/_types.py:682-710):

self_managed_routes = [
    "/team/member_add",
    "/team/member_delete",
    "/team/member_update",
    "/team/permissions_list",
    "/team/permissions_update",
    "/team/daily/activity",
    "/team/{team_id}/members/me",
    ...
]

These routes pass the auth wrapper unconditionally and rely on in-handler checks (_verify_team_access / _user_has_admin_privileges) for per-team authorization. /team/update was simply omitted from this list.

Same gap likely affects /team/delete, /team/block, /team/unblock

Searching for _verify_team_access callers shows the same pattern in team_endpoints.py:3079, 3651, 3703 — these handlers also rely on in-handler authorization, but their routes are not in self_managed_routes. They will exhibit the same 401 for org admins.

Backend / data verification

  • LiteLLM_OrganizationMembership schema (schema.prisma:626-645) stores user_role as a string, with org_admin corresponding to LitellmUserRoles.ORG_ADMIN.value (_types.py:125).
  • _is_user_org_admin_for_team (management_endpoints/common_utils.py:68-105) correctly reads organization_memberships via get_user_object. The data is fine; it just never gets queried because the request is rejected at the route gate.
  • Test coverage: tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py has many test_update_team_* cases but none exercise org admin callers — every test uses a PROXY_ADMIN. The bug would not have been caught by the existing suite.

Expected behavior

  • An org_admin of organization O should be able to call POST /team/update on any team whose organization_id == O, with or without organization_id in the request body.
  • The handler's _verify_team_access should be the authoritative per-team authorization gate; the route-level gate should pass any authenticated user for routes that have their own in-handler scope check.

Actual behavior

  • POST /team/update returns 401 Authentication Error with body containing "Only proxy admin can be used to generate, delete, update info for new keys/users/teams. Route=/team/update. Your role=internal_user. Your user_id=...".
  • The same caller can successfully call /team/member_add, /team/member_update, and /team/member_delete against the same team, which makes the 401 surprising.

Error message

{
  "error": {
    "message": "Authentication Error, Only proxy admin can be used to generate, delete, update info for new keys/users/teams. Route=/team/update. Your role=internal_user. Your user_id=...",
    "type": "auth_error",
    "code": 401
  }
}

Steps to Reproduce

  1. As a proxy_admin, create an organization: POST /organization/new with {"organization_alias": "acme"}.

  2. Create a user with global role internal_user: POST /user/new with {"user_id": "u1", "user_role": "internal_user"}.

  3. Add the user as org_admin of the organization: POST /organization/member_add with {"organization_id": "<acme-id>", "member": {"user_id": "u1", "role": "org_admin"}}.

  4. Create a team in that organization: POST /team/new with {"team_alias": "t1", "organization_id": "<acme-id>"}.

  5. Issue a virtual key for u1: POST /key/generate with {"user_id": "u1"}.

  6. Using u1's key, call:

    curl -X POST http://localhost:4000/team/update \
      -H "Authorization: Bearer <u1-key>" \
      -H "Content-Type: application/json" \
      -d '{"team_id": "<t1-id>", "max_budget": 10}'

Observe: 401 response with the "Only proxy admin can be used to..." message, despite u1 being a valid org_admin of the team's organization.

  1. (Optional) The same caller can successfully call POST /team/member_add on the same team — confirming that the org-admin permission is recognized by the handler-level checks; only the route gate rejects.

Suggested fix

Add /team/update to LiteLLMRoutes.self_managed_routes

In litellm/proxy/_types.py, extend self_managed_routes (currently lines 682-710) to include /team/update. The handler already uses _verify_team_access for in-handler authorization, matching the pattern of the /team/member_* siblings already in this list:

self_managed_routes = [
    "/team/member_add",
    "/team/member_delete",
    "/team/member_update",
    "/team/permissions_list",
    "/team/permissions_update",
    "/team/daily/activity",
    "/team/{team_id}/members/me",
    # /team/update is also self-managed: its handler calls
    # _verify_team_access for per-team scope (proxy admin / team admin /
    # org admin of the team's organization).
    "/team/update",
    ...
]

Same gap likely affects /team/delete, /team/block, and /team/unblock — all three call _verify_team_access inside the handler (team_endpoints.py:3079, 3651, 3703) and none are in self_managed_routes. Tracking those in the same fix is reasonable but out of scope for this report.

Why this works:

  • _verify_team_access (team_endpoints.py:130-158) is the authoritative per-team gate and already supports proxy admins, team admins (via _is_user_team_admin), and org admins of the team's org (via _is_user_org_admin_for_team).
  • The pattern matches existing siblings in self_managed_routes: /team/member_add, /team/member_delete, /team/member_update, /team/permissions_update — all destructive, all rely on in-handler authorization, all already in this list.
  • No new helpers, no DB calls in the auth hot path, no schema changes.
  • No security regression: any non-admin caller that reaches the handler is rejected by _verify_team_access with 403 You do not have access to this team.

No backend logic changes beyond the list addition. No schema migration. No breaking changes for existing callers.

Tests to add

In tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py:

  • test_update_team_org_admin_allowed: org_admin of the team's org calls /team/update with only team_id in the body — expect 200.
  • test_update_team_org_admin_other_org_blocked: org_admin of org A calls /team/update for a team in org B — expect 403 from _verify_team_access.
  • test_update_team_internal_user_no_membership_blocked: bare internal_user with no team or org admin membership calls /team/update — expect 403 from _verify_team_access (route gate passes, handler rejects).

In tests/test_litellm/proxy/auth/test_route_checks.py:

  • test_org_admin_can_pass_route_gate_for_team_update_without_organization_id: call non_proxy_admin_allowed_routes_check with route="/team/update", request_data={"team_id": "..."}, and a user with INTERNAL_USER global role plus an org_admin membership — expect no exception.

Related code locations

ComponentFileLinesIssue
Route gate (rejects request)litellm/proxy/auth/route_checks.py192-305, 258-263, 303-305non_proxy_admin_allowed_routes_check falls through to _raise_admin_only_route_exception for org admins on /team/update
Org-admin route helperlitellm/proxy/auth/auth_checks_organization.py142-180_user_is_org_admin returns False when request body has no organization_id
Route registry — management_routeslitellm/proxy/_types.py547-585/team/update is here
Route registry — self_managed_routes (the missing entry)litellm/proxy/_types.py682-710/team/update should be here, alongside /team/member_* siblings
Org-admin allowed routes (already includes /team/update)litellm/proxy/_types.py798-803Inclusion is correct, but unreachable due to route gate
Endpoint handlerlitellm/proxy/management_endpoints/team_endpoints.py1498-1634update_team, calls _verify_team_access at line 1631
Team-access helperlitellm/proxy/management_endpoints/team_endpoints.py130-158_verify_team_access — correct in-handler gate
Org-admin-of-team helperlitellm/proxy/management_endpoints/common_utils.py68-105_is_user_org_admin_for_team — works correctly when reached
Request schemalitellm/proxy/_types.py1832-1886UpdateTeamRequest: team_id required, organization_id optional
Sibling routes with same gaplitellm/proxy/management_endpoints/team_endpoints.py3021, 3079, 3651, 3703/team/delete, /team/block, /team/unblock also call _verify_team_access and are not in self_managed_routes
Org membership schemalitellm/proxy/schema.prisma626-645LiteLLM_OrganizationMembership (stores org_admin role)

Relevant log output

What part of LiteLLM is this about?

Proxy

What LiteLLM version are you on ?

v1.83.10

Twitter / LinkedIn details

No response

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

FAQ

Expected behavior

  • An org_admin of organization O should be able to call POST /team/update on any team whose organization_id == O, with or without organization_id in the request body.
  • The handler's _verify_team_access should be the authoritative per-team authorization gate; the route-level gate should pass any authenticated user for routes that have their own in-handler scope check.

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING

litellm - 💡(How to fix) Fix [Bug]: org_admin user receives 401 on POST /team/update despite being authorized [1 participants]