litellm/tests/proxy_behavior/management/test_key_info.py
Yuneng Jiang f5bd477292
test(proxy_behavior): pin /key/info authz matrix (24 scenarios)
Slice 8 of the management-endpoints behavior-pinning effort. 8 actors ×
3 target keys (own, OWNER's key in org_a, CROSS_ORG_USER's key in org_b)
covering self-read, same-team-peer read, and cross-org read.

Notable pinned behaviors (intentionally surfaced for review, not "fixed"):

  * ORG_ADMIN gets 403 on individual key info even within their own org
    — visibility is scoped to "your own keys" + "your team's keys", not
    "your org's keys".
  * Same-team peers (INTERNAL_USER, UNRELATED_SAME_ORG, SERVICE_ACCOUNT)
    DO see each other's keys. Whether that is desired is for the team
    to decide; this PR only pins the existing behavior so unintentional
    changes flip the matrix red.

Wall-time is unchanged (~4.3s for the slice on its own).

Plan: https://www.notion.so/36643b8acdab8128a581ced0f6a4744d
2026-05-19 21:43:38 -07:00

103 lines
4.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Slice 8 — actor × target authz matrix for ``GET /key/info``.
Targets are three fixed seeded keys representing the canonical relations:
* **own** — the actor's own key (each actor's relation to itself).
* **owner_key** — OWNER actor's key in org_a / team_alpha. From any
other org_a / team_alpha actor's perspective this is "same-team, not
mine". From cross_org_user's perspective this is cross-org.
* **cross_org_user_key** — CROSS_ORG_USER's key in org_b / team_beta.
From any org_a actor's perspective this is cross-org.
24 (actor × target) scenarios after applying the matrix; status codes pinned
against current handler behavior so future PRs that change the visibility
boundary turn the suite red.
"""
import pytest
from .actors import Actor
from .conftest import MASTER_KEY # noqa: F401 (kept for symmetry with sibling files)
pytestmark = pytest.mark.asyncio(loop_scope="session")
# (id, actor, target_actor, expected_status)
_SCENARIOS = [
# ─── target = own key ─────────────────────────────────────────────────
("own/proxy_admin", Actor.PROXY_ADMIN, Actor.PROXY_ADMIN, 200),
("own/org_admin", Actor.ORG_ADMIN, Actor.ORG_ADMIN, 200),
("own/team_admin", Actor.TEAM_ADMIN, Actor.TEAM_ADMIN, 200),
("own/internal_user", Actor.INTERNAL_USER, Actor.INTERNAL_USER, 200),
("own/owner", Actor.OWNER, Actor.OWNER, 200),
("own/unrelated_same_org", Actor.UNRELATED_SAME_ORG, Actor.UNRELATED_SAME_ORG, 200),
("own/cross_org_user", Actor.CROSS_ORG_USER, Actor.CROSS_ORG_USER, 200),
("own/service_account", Actor.SERVICE_ACCOUNT, Actor.SERVICE_ACCOUNT, 200),
# ─── target = OWNER's key (org_a / team_alpha) ────────────────────────
# NB: org_admin currently 403s on individual key info even within their own
# org — visibility is scoped to "your own keys" + "your team's keys", not
# "your org's keys". Same-team peers (internal_user, unrelated_same_org,
# service_account) DO see each other's keys.
("owner_key/proxy_admin", Actor.PROXY_ADMIN, Actor.OWNER, 200),
("owner_key/org_admin", Actor.ORG_ADMIN, Actor.OWNER, 403),
("owner_key/team_admin", Actor.TEAM_ADMIN, Actor.OWNER, 200),
("owner_key/internal_user", Actor.INTERNAL_USER, Actor.OWNER, 200),
("owner_key/owner", Actor.OWNER, Actor.OWNER, 200),
("owner_key/unrelated_same_org", Actor.UNRELATED_SAME_ORG, Actor.OWNER, 200),
("owner_key/cross_org_user", Actor.CROSS_ORG_USER, Actor.OWNER, 403),
("owner_key/service_account", Actor.SERVICE_ACCOUNT, Actor.OWNER, 200),
# ─── target = CROSS_ORG_USER's key (org_b / team_beta) ────────────────
("cross_org/proxy_admin", Actor.PROXY_ADMIN, Actor.CROSS_ORG_USER, 200),
("cross_org/org_admin", Actor.ORG_ADMIN, Actor.CROSS_ORG_USER, 403),
("cross_org/team_admin", Actor.TEAM_ADMIN, Actor.CROSS_ORG_USER, 403),
("cross_org/internal_user", Actor.INTERNAL_USER, Actor.CROSS_ORG_USER, 403),
("cross_org/owner", Actor.OWNER, Actor.CROSS_ORG_USER, 403),
(
"cross_org/unrelated_same_org",
Actor.UNRELATED_SAME_ORG,
Actor.CROSS_ORG_USER,
403,
),
("cross_org/cross_org_user", Actor.CROSS_ORG_USER, Actor.CROSS_ORG_USER, 200),
("cross_org/service_account", Actor.SERVICE_ACCOUNT, Actor.CROSS_ORG_USER, 403),
]
@pytest.mark.parametrize(
"actor,target_actor,expected_status",
[(a, t, s) for (_id, a, t, s) in _SCENARIOS],
ids=[s[0] for s in _SCENARIOS],
)
async def test_key_info_authz_matrix(
actor: Actor,
target_actor: Actor,
expected_status: int,
proxy_client,
world,
):
caller = world.keys[actor]
target = world.keys[target_actor]
resp = await proxy_client.get(
f"/key/info?key={target.cleartext}",
headers={"Authorization": f"Bearer {caller.cleartext}"},
)
assert resp.status_code == expected_status, (
f"{actor.value} GET /key/info?key=<{target_actor.value}> → "
f"{resp.status_code} (expected {expected_status}). body={resp.text}"
)
if expected_status == 200:
body = resp.json()
# The handler echoes back whatever ``key`` was passed in the query string,
# so body["key"] == cleartext when we querystring it. When the auth key
# IS the target (no query), it returns the hashed form. Either match is
# fine — the canonical identity check is on info.user_id.
assert body.get("key") in (target.cleartext, target.hashed), (
f"{actor.value} → target {target_actor.value}: wrong key in response "
f"(got {body.get('key')!r}, expected cleartext or hashed of target)"
)
assert body["info"].get("user_id") == target.user_id, (
f"{actor.value} → target {target_actor.value}: wrong user_id "
f"(got {body['info'].get('user_id')!r}, expected {target.user_id!r})"
)