litellm/tests/unit/proxy/management/users/test_service.py
ryan-crabbe-berri 632b69b5c8
refactor(proxy): answer every team access check with TeamAccess.allows (#43364)
* refactor(proxy): route every team-admin decision through auth/team_access.py

Move the six team-admin helpers out of common_utils, team_endpoints and
key_management_endpoints into litellm/proxy/auth/team_access.py under public
names, and point every management route and helper at them. The key routes
keep checking team admin before org admin, so a team admin whose user row is
gone still passes as before. Status codes and bodies are unchanged, which the
223-case team-admin matrix confirms at the merge base and at the tip

common_utils keeps `_is_user_team_admin` as an alias because the published
litellm-enterprise 0.1.71 wheel still imports it from there

* refactor(proxy): answer every team access check with TeamAccess.allows

Replace the six helpers in auth/team_access.py with one resolver in
litellm/proxy/management/teams/access.py. Each route passes the roles it
accepts (TEAM_OR_ORG_ADMIN or TEAM_ADMIN_ONLY), and /team/update and
/team/info rank roles through strongest_role so org admin still outranks
team admin there

The org lookup moves behind an OrgRoles protocol, implemented by
PrismaOrgRoles in management/users/service.py, and get_team_access in
management/teams/dependencies.py is the only place that reads proxy_server
globals. _check_key_admin_access keeps its name and body from main

Routes that checked org admin first now read the roster first, so a team
admin whose org lookup errors now passes on /team/delete, /team/block,
/team/unblock, member reset_spend and reset_budget, and the team callback
routes. No allowed caller is denied
2026-09-30 15:27:33 -07:00

53 lines
2.1 KiB
Python

from __future__ import annotations
from datetime import datetime, timezone
from typing import Final
import pytest
from litellm.caching.dual_cache import DualCache
from litellm.proxy._types import LiteLLM_OrganizationMembershipTable, LiteLLM_UserTable, LitellmUserRoles
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
from litellm.proxy.management.users.service import PrismaOrgRoles, holds_org_admin
from litellm.proxy.utils import ProxyLogging
NOW: Final = datetime.now(timezone.utc)
def user_in(*memberships: tuple[str, str]) -> LiteLLM_UserTable:
return LiteLLM_UserTable(
user_id="u1",
organization_memberships=[
LiteLLM_OrganizationMembershipTable(
user_id="u1", organization_id=organization_id, user_role=role, created_at=NOW, updated_at=NOW
)
for organization_id, role in memberships
],
)
@pytest.mark.parametrize(
("user", "expected"),
[
(user_in(("org-1", LitellmUserRoles.ORG_ADMIN.value)), True),
(user_in(("org-2", LitellmUserRoles.ORG_ADMIN.value)), False),
(user_in(("org-1", LitellmUserRoles.INTERNAL_USER.value)), False),
(user_in(("org-2", LitellmUserRoles.ORG_ADMIN.value), ("org-1", LitellmUserRoles.ORG_ADMIN.value)), True),
(user_in(), False),
(LiteLLM_UserTable(user_id="u1", organization_memberships=None), False),
(None, False),
],
)
def test_holds_org_admin_needs_the_org_admin_role_in_that_org(user: LiteLLM_UserTable | None, expected: bool) -> None:
assert holds_org_admin(user, "org-1") is expected
@pytest.mark.parametrize(
("organization_id", "expected"),
[("org-1", True), ("org-2", False)],
)
async def test_prisma_org_roles_answers_from_the_cached_user_row(organization_id: str, expected: bool) -> None:
cache: Final = UserApiKeyCache()
await cache.async_set_cache(key="u1", value=user_in(("org-1", LitellmUserRoles.ORG_ADMIN.value)))
roles: Final = PrismaOrgRoles(None, cache, ProxyLogging(user_api_key_cache=DualCache()))
assert await roles.is_org_admin("u1", organization_id) is expected