litellm/tests/proxy_behavior/management/test_key_delete.py
Yuneng Jiang 15c7a59d76
ci(proxy-mgmt-behavior): seed scratch keys via proxy_admin actor, not master
Second CI run failed: ``/key/generate`` with explicit ``user_id`` returned
403 "User can only create keys for themselves. Got user_id=X, Your ID=None"
in every test that called ``_create_scratch_key`` with a per-actor user_id.
The bare master key's auth path was producing ``user_id=None`` in the
fresh CI Postgres, which doesn't trigger the PROXY_ADMIN bypass in
``_user_can_only_create_keys_for_themselves`` reliably. Locally the same
master key path worked, masking the issue.

Fix: every ``_create_scratch_key`` helper now takes a seeder cleartext
and the test bodies pass ``world.keys[Actor.PROXY_ADMIN].cleartext``.
That actor was seeded with ``user_role=PROXY_ADMIN`` AND a concrete
``user_id``, so the bypass fires deterministically in both environments.

No behavior shift in the matrices themselves — all 128 scenarios still
pass locally; only the setup helper's auth identity changed.

The bare-master smoke (test_smoke + test_scratch_teardown) is intentionally
left on the master key path: those tests don't pass ``user_id`` in the
body so they don't hit the user_id-mismatch gate.
2026-05-19 22:15:17 -07:00

156 lines
6.2 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 12 — actor × target authz matrix for ``POST /key/delete``.
Same shape as Slices 10/11: master-seed a scoped scratch key, the actor under
test attempts to delete it via ``POST /key/delete {keys: [<cleartext>]}``. On
200 the test verifies the row is gone (or soft-deleted) AND the cleartext can
no longer auth. On denial it verifies the row survives and still authenticates.
"""
from typing import Any, Dict, Optional
import pytest
from .actors import TEAM_ALPHA, TEAM_BETA, Actor
pytestmark = pytest.mark.asyncio(loop_scope="session")
_SCENARIOS = [
# ─── target = self-owned key ──────────────────────────────────────────
("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),
# ─── target = OWNER-scoped key in org_a / team_alpha ──────────────────
("owner_target/proxy_admin", Actor.PROXY_ADMIN, "owner", 200),
# ORG_ADMIN hits the early role gate before any target-specific check.
("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", 403),
("owner_target/unrelated_same_org", Actor.UNRELATED_SAME_ORG, "owner", 403),
("owner_target/cross_org_user", Actor.CROSS_ORG_USER, "owner", 403),
("owner_target/service_account", Actor.SERVICE_ACCOUNT, "owner", 403),
# ─── target = CROSS_ORG_USER-scoped key in org_b / team_beta ──────────
("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", 403),
("cross_org_target/owner", Actor.OWNER, "cross_org", 403),
("cross_org_target/cross_org_user", Actor.CROSS_ORG_USER, "cross_org", 200),
("cross_org_target/service_account", Actor.SERVICE_ACCOUNT, "cross_org", 403),
]
async def _create_scratch_key(
proxy_client,
seeder_cleartext: str,
scratch_prefix: str,
*,
user_id: str,
team_id: Optional[str] = None,
) -> str:
"""Seed a scratch key using the proxy_admin actor (not the bare master key).
The seeded proxy_admin actor's auth path produces user_role=PROXY_ADMIN
+ a concrete user_id from the DB, which deterministically triggers the
``_user_can_only_create_keys_for_themselves`` PROXY_ADMIN bypass. The
bare master key takes a different auth resolution path whose behavior
differs between fresh-CI and warm-local environments.
"""
body: Dict[str, Any] = {"key_alias": scratch_prefix, "user_id": user_id}
if team_id is not None:
body["team_id"] = team_id
resp = await proxy_client.post(
"/key/generate",
headers={"Authorization": f"Bearer {seeder_cleartext}"},
json=body,
)
assert resp.status_code == 200, f"setup: seeder /key/generate failed: {resp.text}"
return resp.json()["key"]
@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_delete_authz_matrix(
actor: Actor,
target_shape: str,
expected_status: int,
proxy_client,
prisma,
scratch,
world,
):
from litellm.proxy.utils import hash_token
caller = world.keys[actor]
if target_shape == "self":
target_cleartext = await _create_scratch_key(
proxy_client,
world.keys[Actor.PROXY_ADMIN].cleartext,
scratch.prefix,
user_id=caller.user_id,
)
elif target_shape == "owner":
target_cleartext = await _create_scratch_key(
proxy_client,
world.keys[Actor.PROXY_ADMIN].cleartext,
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,
world.keys[Actor.PROXY_ADMIN].cleartext,
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}")
target_hashed = hash_token(target_cleartext)
resp = await proxy_client.post(
"/key/delete",
headers={"Authorization": f"Bearer {caller.cleartext}"},
json={"keys": [target_cleartext]},
)
assert resp.status_code == expected_status, (
f"{actor.value} POST /key/delete {target_shape} → "
f"{resp.status_code} (expected {expected_status}). body={resp.text}"
)
# Verify the after-state matches the verdict.
row = await prisma.db.litellm_verificationtoken.find_unique(
where={"token": target_hashed}
)
auth_check = await proxy_client.get(
"/key/info",
headers={"Authorization": f"Bearer {target_cleartext}"},
)
if expected_status == 200:
# Successful delete: cleartext must no longer authenticate, regardless of
# whether the row is hard-deleted or soft-deleted into LiteLLM_DeletedVerificationToken.
assert auth_check.status_code == 401, (
f"{actor.value}: handler returned 200 but cleartext still authenticates "
f"({auth_check.status_code}): {auth_check.text}"
)
else:
# Denied: row still present, cleartext still works.
assert row is not None, (
f"{actor.value}: handler returned {expected_status} but row vanished — "
f"silent delete on denial"
)
assert auth_check.status_code == 200, (
f"{actor.value}: handler returned {expected_status} but cleartext no "
f"longer authenticates: {auth_check.text}"
)