mirror of
https://github.com/tiennm99/litellm.git
synced 2026-07-10 17:04:47 +00:00
f62ae93e13
* test(proxy): add create_scratch_actor harness helper
Adds create_scratch_actor() to the management behavior-suite conftest and
extends create_scratch_team() with team_member_permissions / models kwargs,
needed by the PR3 team-key-permission and team-model matrices. The new
helper mints a scratch-prefixed user + verification token (+ org
memberships), all reclaimed by the existing scratch-prefix teardown.
* test(proxy): pin /key block, unblock, health, aliases behavior
Adds behavior-pinning matrices for POST /key/block, POST /key/unblock,
POST /key/health, and GET /key/aliases. Pins that the management-route gate
401s ORG_ADMIN-role callers before _check_key_admin_access runs, the
block/unblock round-trip on the blocked column, missing-key 404, and the
_apply_non_admin_alias_scope visibility rules for /key/aliases.
* test(proxy): pin /key/bulk_update + /team/key/bulk_update behavior
Adds behavior-pinning matrices for POST /key/bulk_update (PROXY_ADMIN-only;
ORG_ADMIN stopped 401 at the route gate, INTERNAL_USER-role 403 at the
handler) and POST /team/key/bulk_update (team-member-permission gate keyed
on KEY_UPDATE). Pins batch semantics: empty/over-cap 400, per-key failure
isolation into failed_updates, all_keys_in_team broadcast, and no-keys 404.
Adds an optional key_alias arg to create_scratch_key for multi-key scenarios.
* test(proxy): pin /key SA-generate, v2-info, reset-spend behavior
Adds behavior-pinning matrices for POST /key/service-account/generate
(team-membership + team-member-permission gating; SA keys carry no user_id),
POST /v2/key/info (per-key _can_user_query_key_info silently drops invisible
keys), and POST /key/{key}/reset_spend (PROXY_ADMIN or team admin only;
missing key 404, reset-value 400). Pins that ORG_ADMIN-role callers are
stopped 401 at the management-route gate on the two non-info routes.
* test(proxy): close PR1/PR2 key-side deferred coverage gaps
Closes the four key-side gaps deferred from PR1/PR2:
- 404 on missing key for /key/update and /key/delete (not 401/403)
- denied /key/update leaves max_budget/tpm_limit/rpm_limit untouched
- /key/regenerate enforces litellm.upperbound_key_generate_params (#26340)
- /key/list key_alias substring vs exact (admin-only) + team_id filter,
and a non-admin filtering a foreign team is 403
* test(proxy): pin /team block, unblock, available, filter/ui, members/me
Adds behavior-pinning matrices for POST /team/block + /team/unblock
(management-route gate fronts _verify_team_access; reachable only by
PROXY_ADMIN and an org admin of the team's own org), GET /team/available
(default empty path), GET /team/filter/ui (route-gated PROXY-ADMIN-only
despite the handler having no gate), and GET /team/{team_id}/members/me
(caller resolves its own membership; non-member 404, no-user_id key 400).
* test(proxy): pin /team model add/delete + permissions endpoints
Adds behavior-pinning matrices for POST /team/model/add + /team/model/delete
(route-gated PROXY-ADMIN-only; missing team 404), GET /team/permissions_list +
POST /team/permissions_update (self-managed; proxy/team/org admin pass), and
POST /team/permissions_bulk_update (PROXY_ADMIN-only). Pins the deliberate
divergence that the available-team self-join grants read access via
permissions_list but never write access via permissions_update.
* test(proxy): pin /team delete, bulk_member_add, v2/list, daily/activity
Adds behavior-pinning matrices for POST /team/delete (per-team
_verify_team_access; batch aborts whole on a missing id), POST
/team/bulk_member_add (route-gated PROXY-ADMIN-only; empty/over-cap 400),
GET /v2/team/list (_enforce_list_team_v2_access — bare query 401s regular
users, org-scoped for org admins) and GET /team/daily/activity (non-member
team_ids filter 404, the VERIA-43 fix).
* test(proxy): add route-coverage gate + close team org-relocation gap
Adds test_route_coverage.py (PR3.M1): parses every @router route literal
from the two management-endpoint source files and asserts each is exercised
by >=1 behavior-suite scenario — a permanent regression guard for future
routes. Closes the last PR1/PR2 deferred gap: the /team/update org-relocation
allowed branch, exercised by a dual-org-admin minted via create_scratch_actor.
test_team_model uses literal route URLs so the coverage parser resolves them.
* test(proxy): bound plain route params to one path segment in coverage gate
Plain path params ({team_id}) now compile to [^/?]+ instead of [^?]+, so a
parameter cannot span '/'. Starlette ':path' params still match across '/'.
Keeps the route-coverage guard from falsely reporting a future multi-segment
route as covered. All 37 routes remain covered.
210 lines
7.6 KiB
Python
210 lines
7.6 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).
|
|
# Each request carries the team's own organization_id so a non-proxy-admin can
|
|
# reach the org-scoped branch of the route-permission gate (401 on denial),
|
|
# which fronts the handler's _verify_team_access. Only PROXY_ADMIN and an
|
|
# ORG_ADMIN of the team's org pass: an internal_user team admin is filtered by
|
|
# the route gate before _verify_team_access's team-admin branch is reached.
|
|
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", 401),
|
|
("alpha/internal_user", Actor.INTERNAL_USER, "alpha", 401),
|
|
("alpha/owner", Actor.OWNER, "alpha", 401),
|
|
("alpha/unrelated_same_org", Actor.UNRELATED_SAME_ORG, "alpha", 401),
|
|
("alpha/cross_org_user", Actor.CROSS_ORG_USER, "alpha", 401),
|
|
("alpha/service_account", Actor.SERVICE_ACCOUNT, "alpha", 401),
|
|
("alpha/org_b_admin", Actor.ORG_B_ADMIN, "alpha", 401),
|
|
("beta/proxy_admin", Actor.PROXY_ADMIN, "beta", 200),
|
|
("beta/org_admin", Actor.ORG_ADMIN, "beta", 401),
|
|
("beta/team_admin", Actor.TEAM_ADMIN, "beta", 401),
|
|
("beta/internal_user", Actor.INTERNAL_USER, "beta", 401),
|
|
("beta/owner", Actor.OWNER, "beta", 401),
|
|
("beta/unrelated_same_org", Actor.UNRELATED_SAME_ORG, "beta", 401),
|
|
("beta/cross_org_user", Actor.CROSS_ORG_USER, "beta", 401),
|
|
("beta/service_account", Actor.SERVICE_ACCOUNT, "beta", 401),
|
|
("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_requires_proxy_admin_without_org_context(
|
|
proxy_client, prisma, scratch, world
|
|
):
|
|
"""With no organization_id in the body the route gate has no org context
|
|
and falls back to proxy-admin-only: an org admin of the team's own org
|
|
is 401, PROXY_ADMIN is 200."""
|
|
await _seed_target(prisma, world, "alpha", scratch.prefix)
|
|
|
|
denied = 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 denied.status_code == 401, denied.text
|
|
|
|
allowed = 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.status_code == 200, allowed.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 clears the route gate (dest-org admin) but fails
|
|
# _verify_team_access on the source team (403); the rest fail the route gate
|
|
# (401). 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, 401),
|
|
("team_admin", Actor.TEAM_ADMIN, 401),
|
|
("internal_user", Actor.INTERNAL_USER, 401),
|
|
]
|
|
|
|
|
|
@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"
|
|
|
|
|
|
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"
|