mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-03 02:22:24 +00:00
* 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
86 lines
3.8 KiB
Python
86 lines
3.8 KiB
Python
import pytest
|
|
|
|
from .actors import Actor
|
|
|
|
pytestmark = pytest.mark.asyncio(loop_scope="session")
|
|
|
|
|
|
# GET /team/info — actor x team-target authz matrix, pinned against
|
|
# validate_membership(): a team is readable by a proxy admin, a key whose
|
|
# own team_id matches, a listed member, or an org admin of the team's org;
|
|
# everything else is 403. TEAM_GAMMA has no members, so only PROXY_ADMIN
|
|
# and ORG_A's org admin can read it.
|
|
_SCENARIOS = [
|
|
("alpha/proxy_admin", Actor.PROXY_ADMIN, "alpha", 200),
|
|
("alpha/org_admin", Actor.ORG_ADMIN, "alpha", 200),
|
|
("alpha/team_admin", Actor.TEAM_ADMIN, "alpha", 200),
|
|
("alpha/internal_user", Actor.INTERNAL_USER, "alpha", 200),
|
|
("alpha/owner", Actor.OWNER, "alpha", 200),
|
|
("alpha/unrelated_same_org", Actor.UNRELATED_SAME_ORG, "alpha", 200),
|
|
("alpha/cross_org_user", Actor.CROSS_ORG_USER, "alpha", 403),
|
|
("alpha/service_account", Actor.SERVICE_ACCOUNT, "alpha", 200),
|
|
("alpha/org_b_admin", Actor.ORG_B_ADMIN, "alpha", 403),
|
|
("gamma/proxy_admin", Actor.PROXY_ADMIN, "gamma", 200),
|
|
("gamma/org_admin", Actor.ORG_ADMIN, "gamma", 200),
|
|
("gamma/team_admin", Actor.TEAM_ADMIN, "gamma", 403),
|
|
("gamma/internal_user", Actor.INTERNAL_USER, "gamma", 403),
|
|
("gamma/owner", Actor.OWNER, "gamma", 403),
|
|
("gamma/unrelated_same_org", Actor.UNRELATED_SAME_ORG, "gamma", 403),
|
|
("gamma/cross_org_user", Actor.CROSS_ORG_USER, "gamma", 403),
|
|
("gamma/service_account", Actor.SERVICE_ACCOUNT, "gamma", 403),
|
|
("gamma/org_b_admin", Actor.ORG_B_ADMIN, "gamma", 403),
|
|
("beta/proxy_admin", Actor.PROXY_ADMIN, "beta", 200),
|
|
("beta/org_admin", Actor.ORG_ADMIN, "beta", 403),
|
|
("beta/team_admin", Actor.TEAM_ADMIN, "beta", 403),
|
|
("beta/internal_user", Actor.INTERNAL_USER, "beta", 403),
|
|
("beta/owner", Actor.OWNER, "beta", 403),
|
|
("beta/unrelated_same_org", Actor.UNRELATED_SAME_ORG, "beta", 403),
|
|
("beta/cross_org_user", Actor.CROSS_ORG_USER, "beta", 200),
|
|
("beta/service_account", Actor.SERVICE_ACCOUNT, "beta", 403),
|
|
("beta/org_b_admin", Actor.ORG_B_ADMIN, "beta", 200),
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"actor,target,expected_status",
|
|
[(a, t, s) for (_id, a, t, s) in _SCENARIOS],
|
|
ids=[s[0] for s in _SCENARIOS],
|
|
)
|
|
async def test_team_info_authz_matrix(
|
|
actor: Actor, target: str, expected_status: int, proxy_client, world
|
|
):
|
|
caller = world.keys[actor]
|
|
target_team_id = {
|
|
"alpha": world.team_alpha_id,
|
|
"gamma": world.team_gamma_id,
|
|
"beta": world.team_beta_id,
|
|
}[target]
|
|
|
|
resp = await proxy_client.get(
|
|
f"/team/info?team_id={target_team_id}",
|
|
headers={"Authorization": f"Bearer {caller.cleartext}"},
|
|
)
|
|
assert (
|
|
resp.status_code == expected_status
|
|
), f"{actor.value} -> {target}: {resp.status_code} {resp.text}"
|
|
|
|
if expected_status == 200:
|
|
body = resp.json()
|
|
assert body["team_id"] == target_team_id
|
|
assert body["team_info"]["team_id"] == target_team_id
|
|
|
|
|
|
# Phase 4 F6 — explicit pin on the `team_access_denied` 403 message string.
|
|
# alpha/org_b_admin already covers the branch in the matrix; this guard
|
|
# turns a silent rename of the exception detail into a CI red, which is the
|
|
# behavior tripwire that the matrix's status-only assertion cannot catch.
|
|
async def test_team_info_org_admin_cross_org_rejection_detail(proxy_client, world):
|
|
resp = await proxy_client.get(
|
|
f"/team/info?team_id={world.team_alpha_id}",
|
|
headers={"Authorization": f"Bearer {world.keys[Actor.ORG_B_ADMIN].cleartext}"},
|
|
)
|
|
assert resp.status_code == 403, resp.text
|
|
# validate_membership() at GET /team/info raises with this exact phrase.
|
|
# Pinning it lock-step locks down the visible auth message — a rename
|
|
# would flip CI red even when the status stays 403.
|
|
assert "not authorized to access this team" in resp.text, resp.text
|