litellm/tests/proxy_behavior/management/test_key_regenerate.py
yuneng-jiang f62ae93e13
test(proxy): behavior-pinning matrix for tier-2/3 key + team management endpoints (#28620)
* 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.
2026-05-22 11:24:41 -07:00

165 lines
6.2 KiB
Python

import litellm
import pytest
from litellm.types.proxy.management_endpoints.ui_sso import (
LiteLLM_UpperboundKeyGenerateParams,
)
from .actors import TEAM_ALPHA, TEAM_BETA, Actor
from .conftest import create_scratch_key
pytestmark = pytest.mark.asyncio(loop_scope="session")
# Most denials route through team_member_permission (401), unlike /key/update
# which goes through user_id-mismatch (403). The matrix surfaces that
# divergence between the two endpoints.
_SCENARIOS = [
("self/proxy_admin", Actor.PROXY_ADMIN, "self", 200),
("self/org_admin", Actor.ORG_ADMIN, "self", 401),
("self/team_admin", Actor.TEAM_ADMIN, "self", 200),
("self/internal_user", Actor.INTERNAL_USER, "self", 200),
("self/owner", Actor.OWNER, "self", 200),
("self/unrelated_same_org", Actor.UNRELATED_SAME_ORG, "self", 200),
("self/cross_org_user", Actor.CROSS_ORG_USER, "self", 200),
("self/service_account", Actor.SERVICE_ACCOUNT, "self", 200),
("owner_target/proxy_admin", Actor.PROXY_ADMIN, "owner", 200),
("owner_target/org_admin", Actor.ORG_ADMIN, "owner", 401),
("owner_target/team_admin", Actor.TEAM_ADMIN, "owner", 200),
("owner_target/internal_user", Actor.INTERNAL_USER, "owner", 401),
("owner_target/unrelated_same_org", Actor.UNRELATED_SAME_ORG, "owner", 401),
("owner_target/cross_org_user", Actor.CROSS_ORG_USER, "owner", 401),
("owner_target/service_account", Actor.SERVICE_ACCOUNT, "owner", 401),
("cross_org_target/proxy_admin", Actor.PROXY_ADMIN, "cross_org", 200),
("cross_org_target/org_admin", Actor.ORG_ADMIN, "cross_org", 401),
("cross_org_target/team_admin", Actor.TEAM_ADMIN, "cross_org", 401),
("cross_org_target/owner", Actor.OWNER, "cross_org", 401),
("cross_org_target/cross_org_user", Actor.CROSS_ORG_USER, "cross_org", 401),
("cross_org_target/service_account", Actor.SERVICE_ACCOUNT, "cross_org", 401),
]
async def _info(proxy_client, cleartext: str):
return await proxy_client.get(
"/key/info", headers={"Authorization": f"Bearer {cleartext}"}
)
@pytest.mark.parametrize(
"actor,target_shape,expected_status",
[(a, t, s) for (_id, a, t, s) in _SCENARIOS],
ids=[s[0] for s in _SCENARIOS],
)
async def test_key_regenerate_authz_matrix(
actor: Actor,
target_shape: str,
expected_status: int,
proxy_client,
scratch,
world,
):
caller = world.keys[actor]
seeder = world.keys[Actor.PROXY_ADMIN].cleartext
if target_shape == "self":
target_cleartext = await create_scratch_key(
proxy_client, seeder, scratch.prefix, user_id=caller.user_id
)
elif target_shape == "owner":
target_cleartext = await create_scratch_key(
proxy_client,
seeder,
scratch.prefix,
user_id=world.keys[Actor.OWNER].user_id,
team_id=TEAM_ALPHA,
)
elif target_shape == "cross_org":
target_cleartext = await create_scratch_key(
proxy_client,
seeder,
scratch.prefix,
user_id=world.keys[Actor.CROSS_ORG_USER].user_id,
team_id=TEAM_BETA,
)
else:
pytest.fail(f"unknown target_shape={target_shape}")
resp = await proxy_client.post(
"/key/regenerate",
headers={"Authorization": f"Bearer {caller.cleartext}"},
json={"key": target_cleartext},
)
assert (
resp.status_code == expected_status
), f"{actor.value} {target_shape}: {resp.status_code} {resp.text}"
if expected_status == 200:
new_cleartext = resp.json()["key"]
assert new_cleartext.startswith("sk-") and new_cleartext != target_cleartext
assert (await _info(proxy_client, target_cleartext)).status_code == 401
assert (await _info(proxy_client, new_cleartext)).status_code == 200
else:
# Denied: rotation must not have leaked — old cleartext still works.
assert (await _info(proxy_client, target_cleartext)).status_code == 200
async def test_key_path_regenerate_smoke(proxy_client, scratch, world):
"""Pins that POST /key/{key:path}/regenerate shares the same handler."""
caller = world.keys[Actor.PROXY_ADMIN]
target_cleartext = await create_scratch_key(
proxy_client, caller.cleartext, scratch.prefix, user_id=caller.user_id
)
resp = await proxy_client.post(
f"/key/{target_cleartext}/regenerate",
headers={"Authorization": f"Bearer {caller.cleartext}"},
json={},
)
assert resp.status_code == 200, resp.text
new_cleartext = resp.json()["key"]
assert new_cleartext.startswith("sk-") and new_cleartext != target_cleartext
assert (await _info(proxy_client, target_cleartext)).status_code == 401
assert (await _info(proxy_client, new_cleartext)).status_code == 200
async def test_key_regenerate_enforces_upperbound_key_params(
proxy_client, scratch, world, monkeypatch
):
"""Regenerate runs _enforce_upperbound_key_params: a max_budget above
litellm.upperbound_key_generate_params is rejected 400, a value within the
bound is accepted. Pins #26340 (db8ef44323) — regenerate previously
bypassed the upperbound. upperbound_key_generate_params is module-level
litellm.* state, so monkeypatch save/restores it."""
admin = world.keys[Actor.PROXY_ADMIN]
over_key = await create_scratch_key(
proxy_client,
admin.cleartext,
scratch.prefix,
user_id=admin.user_id,
key_alias=f"{scratch.prefix}-over",
)
within_key = await create_scratch_key(
proxy_client,
admin.cleartext,
scratch.prefix,
user_id=admin.user_id,
key_alias=f"{scratch.prefix}-within",
)
monkeypatch.setattr(
litellm,
"upperbound_key_generate_params",
LiteLLM_UpperboundKeyGenerateParams(max_budget=100.0),
)
headers = {"Authorization": f"Bearer {admin.cleartext}"}
over = await proxy_client.post(
"/key/regenerate", headers=headers, json={"key": over_key, "max_budget": 500.0}
)
assert over.status_code == 400, over.text
within = await proxy_client.post(
"/key/regenerate",
headers=headers,
json={"key": within_key, "max_budget": 50.0},
)
assert within.status_code == 200, within.text