mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-02 02:11:58 +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
240 lines
9.2 KiB
Python
240 lines
9.2 KiB
Python
import pytest
|
|
|
|
from litellm.proxy._types import LitellmUserRoles
|
|
|
|
from .actors import Actor
|
|
from .conftest import create_scratch_actor, create_scratch_team
|
|
|
|
pytestmark = pytest.mark.asyncio(loop_scope="session")
|
|
|
|
|
|
# POST /team/update — actor x team-shape matrix (shapes built by _seed_target).
|
|
# The route is self-managed (LIT-5722), so every authenticated caller reaches
|
|
# update_team and denials are the handler's 403, never the route gate's 401.
|
|
# Only PROXY_ADMIN and an ORG_ADMIN of the team's org pass: a team admin is
|
|
# admitted by TeamAccess.strongest_role but then refused because no team field is
|
|
# enabled for team admins (team_admin_editable_team_fields defaults to empty).
|
|
MARKER_ALIAS = "behavior-pin-update-marker-alias"
|
|
|
|
_MATRIX = [
|
|
("alpha/proxy_admin", Actor.PROXY_ADMIN, "alpha", 200),
|
|
("alpha/org_admin", Actor.ORG_ADMIN, "alpha", 200),
|
|
("alpha/team_admin", Actor.TEAM_ADMIN, "alpha", 403),
|
|
("alpha/internal_user", Actor.INTERNAL_USER, "alpha", 403),
|
|
("alpha/owner", Actor.OWNER, "alpha", 403),
|
|
("alpha/unrelated_same_org", Actor.UNRELATED_SAME_ORG, "alpha", 403),
|
|
("alpha/cross_org_user", Actor.CROSS_ORG_USER, "alpha", 403),
|
|
("alpha/service_account", Actor.SERVICE_ACCOUNT, "alpha", 403),
|
|
("alpha/org_b_admin", Actor.ORG_B_ADMIN, "alpha", 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", 403),
|
|
("beta/service_account", Actor.SERVICE_ACCOUNT, "beta", 403),
|
|
("beta/org_b_admin", Actor.ORG_B_ADMIN, "beta", 200),
|
|
]
|
|
|
|
|
|
async def _seed_target(prisma, world, shape: str, team_id: str) -> str:
|
|
"""Raw-seed the scratch target team; returns its organization_id."""
|
|
if shape == "alpha":
|
|
await create_scratch_team(
|
|
prisma,
|
|
team_id,
|
|
organization_id=world.org_a_id,
|
|
admin_user_ids=[world.keys[Actor.TEAM_ADMIN].user_id],
|
|
member_user_ids=[
|
|
world.keys[Actor.INTERNAL_USER].user_id,
|
|
world.keys[Actor.OWNER].user_id,
|
|
world.keys[Actor.UNRELATED_SAME_ORG].user_id,
|
|
world.keys[Actor.SERVICE_ACCOUNT].user_id,
|
|
],
|
|
)
|
|
return world.org_a_id
|
|
if shape == "beta":
|
|
await create_scratch_team(
|
|
prisma,
|
|
team_id,
|
|
organization_id=world.org_b_id,
|
|
member_user_ids=[world.keys[Actor.CROSS_ORG_USER].user_id],
|
|
)
|
|
return world.org_b_id
|
|
pytest.fail(f"unknown shape={shape}") # pragma: no cover
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"actor,shape,expected_status",
|
|
[(a, sh, s) for (_id, a, sh, s) in _MATRIX],
|
|
ids=[s[0] for s in _MATRIX],
|
|
)
|
|
async def test_team_update_authz_matrix(
|
|
actor: Actor,
|
|
shape: str,
|
|
expected_status: int,
|
|
proxy_client,
|
|
prisma,
|
|
scratch,
|
|
world,
|
|
):
|
|
org_id = await _seed_target(prisma, world, shape, scratch.prefix)
|
|
caller = world.keys[actor]
|
|
|
|
resp = await proxy_client.post(
|
|
"/team/update",
|
|
headers={"Authorization": f"Bearer {caller.cleartext}"},
|
|
json={
|
|
"team_id": scratch.prefix,
|
|
"team_alias": MARKER_ALIAS,
|
|
"organization_id": org_id,
|
|
},
|
|
)
|
|
assert (
|
|
resp.status_code == expected_status
|
|
), f"{actor.value} {shape}: {resp.status_code} {resp.text}"
|
|
|
|
row = await prisma.db.litellm_teamtable.find_unique(
|
|
where={"team_id": scratch.prefix}
|
|
)
|
|
assert row is not None
|
|
if expected_status == 200:
|
|
assert row.team_alias == MARKER_ALIAS
|
|
else:
|
|
assert row.team_alias != MARKER_ALIAS, "denied but team mutated"
|
|
|
|
|
|
async def test_team_update_org_admin_resolved_from_team_without_org_context(
|
|
proxy_client, prisma, scratch, world
|
|
):
|
|
"""With no organization_id in the body the route gate resolves the target
|
|
team's org from team_id, so an org admin of the team's own org is allowed
|
|
(200), same as PROXY_ADMIN. A team admin of that same team reaches the
|
|
handler but is refused (403) until a proxy admin enables fields for team
|
|
admins, and the response says so."""
|
|
await _seed_target(prisma, world, "alpha", scratch.prefix)
|
|
|
|
allowed_org_admin = await proxy_client.post(
|
|
"/team/update",
|
|
headers={"Authorization": f"Bearer {world.keys[Actor.ORG_ADMIN].cleartext}"},
|
|
json={"team_id": scratch.prefix, "team_alias": MARKER_ALIAS},
|
|
)
|
|
assert allowed_org_admin.status_code == 200, allowed_org_admin.text
|
|
|
|
allowed_proxy_admin = await proxy_client.post(
|
|
"/team/update",
|
|
headers={"Authorization": f"Bearer {world.keys[Actor.PROXY_ADMIN].cleartext}"},
|
|
json={"team_id": scratch.prefix, "team_alias": MARKER_ALIAS},
|
|
)
|
|
assert allowed_proxy_admin.status_code == 200, allowed_proxy_admin.text
|
|
|
|
denied_team_admin = await proxy_client.post(
|
|
"/team/update",
|
|
headers={"Authorization": f"Bearer {world.keys[Actor.TEAM_ADMIN].cleartext}"},
|
|
json={"team_id": scratch.prefix, "team_alias": MARKER_ALIAS},
|
|
)
|
|
assert denied_team_admin.status_code == 403, denied_team_admin.text
|
|
assert "cannot edit team settings" in denied_team_admin.text, denied_team_admin.text
|
|
assert "Team admin editable fields" in denied_team_admin.text, denied_team_admin.text
|
|
|
|
|
|
# Relocation gate — moving a team to a different org. The scratch team starts
|
|
# in ORG_A; each scenario relocates it to ORG_B. PROXY_ADMIN bypasses;
|
|
# ORG_B_ADMIN reaches the handler but holds no role on the source team (403);
|
|
# ORG_ADMIN holds the source team but not the destination org (403 from the
|
|
# relocation gate); the team admin is refused by the empty field allow-list and
|
|
# the internal user holds no role at all (403). The relocation-*allowed* branch
|
|
# (caller is org admin of both orgs) is covered by
|
|
# test_team_update_org_relocation_allowed_for_dual_org_admin below.
|
|
_RELOCATION = [
|
|
("proxy_admin", Actor.PROXY_ADMIN, 200),
|
|
("org_b_admin", Actor.ORG_B_ADMIN, 403),
|
|
("org_admin", Actor.ORG_ADMIN, 403),
|
|
("team_admin", Actor.TEAM_ADMIN, 403),
|
|
("internal_user", Actor.INTERNAL_USER, 403),
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"actor,expected_status",
|
|
[(a, s) for (_id, a, s) in _RELOCATION],
|
|
ids=[s[0] for s in _RELOCATION],
|
|
)
|
|
async def test_team_update_org_relocation_gate(
|
|
actor: Actor,
|
|
expected_status: int,
|
|
proxy_client,
|
|
prisma,
|
|
scratch,
|
|
world,
|
|
):
|
|
await _seed_target(prisma, world, "alpha", scratch.prefix)
|
|
caller = world.keys[actor]
|
|
|
|
resp = await proxy_client.post(
|
|
"/team/update",
|
|
headers={"Authorization": f"Bearer {caller.cleartext}"},
|
|
json={"team_id": scratch.prefix, "organization_id": world.org_b_id},
|
|
)
|
|
assert (
|
|
resp.status_code == expected_status
|
|
), f"{actor.value}: {resp.status_code} {resp.text}"
|
|
|
|
row = await prisma.db.litellm_teamtable.find_unique(
|
|
where={"team_id": scratch.prefix}
|
|
)
|
|
assert row is not None
|
|
if expected_status == 200:
|
|
assert row.organization_id == world.org_b_id
|
|
else:
|
|
assert row.organization_id == world.org_a_id, "denied but team relocated"
|
|
|
|
|
|
# Phase 4 F6 — explicit pin on the `team_access_denied` 403 detail string
|
|
# when an org_admin clears the destination route gate but fails the source
|
|
# team's org-membership check. The relocation matrix above covers the
|
|
# status; this guard turns a silent rename of the helper's exception detail
|
|
# into a CI red.
|
|
async def test_team_update_org_b_admin_relocation_rejection_detail(
|
|
proxy_client, prisma, scratch, world
|
|
):
|
|
await _seed_target(prisma, world, "alpha", scratch.prefix)
|
|
resp = await proxy_client.post(
|
|
"/team/update",
|
|
headers={"Authorization": f"Bearer {world.keys[Actor.ORG_B_ADMIN].cleartext}"},
|
|
json={"team_id": scratch.prefix, "organization_id": world.org_b_id},
|
|
)
|
|
assert resp.status_code == 403, resp.text
|
|
assert "do not have access to this team" in resp.text, resp.text
|
|
|
|
|
|
async def test_team_update_org_relocation_allowed_for_dual_org_admin(
|
|
proxy_client, prisma, scratch, world
|
|
):
|
|
"""Relocation-allowed branch: a caller who is org admin of BOTH the source
|
|
and destination org may relocate a team between them. Completes the
|
|
_RELOCATION matrix, whose allowed branch PR2 left open — no seeded actor is
|
|
a dual-org admin, so one is minted with create_scratch_actor."""
|
|
actor = await create_scratch_actor(
|
|
prisma,
|
|
scratch.prefix,
|
|
user_role=LitellmUserRoles.ORG_ADMIN.value,
|
|
org_admin_of=(world.org_a_id, world.org_b_id),
|
|
)
|
|
team_id = await create_scratch_team(
|
|
prisma, scratch.tag("team"), organization_id=world.org_a_id
|
|
)
|
|
|
|
resp = await proxy_client.post(
|
|
"/team/update",
|
|
headers={"Authorization": f"Bearer {actor.cleartext}"},
|
|
json={"team_id": team_id, "organization_id": world.org_b_id},
|
|
)
|
|
assert resp.status_code == 200, resp.text
|
|
|
|
row = await prisma.db.litellm_teamtable.find_unique(where={"team_id": team_id})
|
|
assert row is not None
|
|
assert (
|
|
row.organization_id == world.org_b_id
|
|
), "dual-org admin relocation not applied"
|