mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-07 08:26:10 +00:00
* feat(teams): apply default organization to new teams from default team settings Adds organization_id to DefaultTeamSSOParams so proxy admins can pick a default organization in Default Team Settings. new_team applies it before org validation whenever a team is created without an explicit organization_id, so API, Admin UI, SCIM, SSO, and team upsert creations all inherit it and go through the same existence and org-limit checks. Explicit organization selections win and existing teams are untouched. The default is validated at save time (PATCH /update/default_team_settings returns 400 for an unknown org) and at create time, where a missing org now surfaces as a clean 400 instead of a 500 by routing OrganizationNotFoundError into the previously dead org_table None guard. The Admin UI Default Team Settings tab gets a Default Organization row backed by the shared OrganizationDropdown. * fix(teams): validate org limits against final team state including defaults Applies default_team_params and the legacy max_budget fallback before the organization validation block, so _check_org_team_limits sees the values the team will actually be persisted with. Also loads the org's budget table in the lookup; without include_budget_table every budget comparison in _check_org_team_limits was skipped because litellm_budget_table was None. * test(proxy_behavior): pin org team limits as enforced on /team/new The dead-code pins existed to turn red when include_budget_table went live; that happened, so the scenarios now assert the 400 rejections plus within-cap acceptance, and the unknown-org pin asserts the handler's 400 instead of the surfaced 500.
130 lines
5.1 KiB
Python
130 lines
5.1 KiB
Python
from typing import Any, Dict
|
|
|
|
import pytest
|
|
|
|
from .actors import Actor
|
|
|
|
pytestmark = pytest.mark.asyncio(loop_scope="session")
|
|
|
|
|
|
# POST /team/new — actor x org-target matrix (org_target picks the request's
|
|
# organization_id: none / ORG_A / ORG_B). Pinned against the role gate, which
|
|
# 401s every denial: PROXY_ADMIN always passes; any other caller must name an
|
|
# organization_id AND be ORG_ADMIN of that org.
|
|
_SCENARIOS = [
|
|
("none/proxy_admin", Actor.PROXY_ADMIN, "none", 200),
|
|
("none/org_admin", Actor.ORG_ADMIN, "none", 401),
|
|
("none/team_admin", Actor.TEAM_ADMIN, "none", 401),
|
|
("none/internal_user", Actor.INTERNAL_USER, "none", 401),
|
|
("none/owner", Actor.OWNER, "none", 401),
|
|
("none/unrelated_same_org", Actor.UNRELATED_SAME_ORG, "none", 401),
|
|
("none/cross_org_user", Actor.CROSS_ORG_USER, "none", 401),
|
|
("none/service_account", Actor.SERVICE_ACCOUNT, "none", 401),
|
|
("none/org_b_admin", Actor.ORG_B_ADMIN, "none", 401),
|
|
("org_a/proxy_admin", Actor.PROXY_ADMIN, "org_a", 200),
|
|
("org_a/org_admin", Actor.ORG_ADMIN, "org_a", 200),
|
|
("org_a/team_admin", Actor.TEAM_ADMIN, "org_a", 401),
|
|
("org_a/internal_user", Actor.INTERNAL_USER, "org_a", 401),
|
|
("org_a/owner", Actor.OWNER, "org_a", 401),
|
|
("org_a/unrelated_same_org", Actor.UNRELATED_SAME_ORG, "org_a", 401),
|
|
("org_a/cross_org_user", Actor.CROSS_ORG_USER, "org_a", 401),
|
|
("org_a/service_account", Actor.SERVICE_ACCOUNT, "org_a", 401),
|
|
("org_a/org_b_admin", Actor.ORG_B_ADMIN, "org_a", 401),
|
|
("org_b/proxy_admin", Actor.PROXY_ADMIN, "org_b", 200),
|
|
("org_b/org_admin", Actor.ORG_ADMIN, "org_b", 401),
|
|
("org_b/team_admin", Actor.TEAM_ADMIN, "org_b", 401),
|
|
("org_b/internal_user", Actor.INTERNAL_USER, "org_b", 401),
|
|
("org_b/owner", Actor.OWNER, "org_b", 401),
|
|
("org_b/unrelated_same_org", Actor.UNRELATED_SAME_ORG, "org_b", 401),
|
|
("org_b/cross_org_user", Actor.CROSS_ORG_USER, "org_b", 401),
|
|
("org_b/service_account", Actor.SERVICE_ACCOUNT, "org_b", 401),
|
|
("org_b/org_b_admin", Actor.ORG_B_ADMIN, "org_b", 200),
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"actor,org_target,expected_status",
|
|
[(a, o, s) for (_id, a, o, s) in _SCENARIOS],
|
|
ids=[s[0] for s in _SCENARIOS],
|
|
)
|
|
async def test_team_new_authz_matrix(
|
|
actor: Actor,
|
|
org_target: str,
|
|
expected_status: int,
|
|
proxy_client,
|
|
prisma,
|
|
scratch,
|
|
world,
|
|
):
|
|
caller = world.keys[actor]
|
|
org_id = {
|
|
"none": None,
|
|
"org_a": world.org_a_id,
|
|
"org_b": world.org_b_id,
|
|
}[org_target]
|
|
|
|
body: Dict[str, Any] = {"team_id": scratch.prefix, "team_alias": scratch.prefix}
|
|
if org_id is not None:
|
|
body["organization_id"] = org_id
|
|
|
|
resp = await proxy_client.post(
|
|
"/team/new",
|
|
headers={"Authorization": f"Bearer {caller.cleartext}"},
|
|
json=body,
|
|
)
|
|
assert resp.status_code == expected_status, f"{actor.value} org={org_target}: {resp.status_code} {resp.text}"
|
|
|
|
row = await prisma.db.litellm_teamtable.find_unique(where={"team_id": scratch.prefix})
|
|
if expected_status == 200:
|
|
assert row is not None
|
|
assert row.organization_id == org_id
|
|
else:
|
|
assert row is None, f"{actor.value}: denied but team row leaked"
|
|
|
|
|
|
async def test_team_new_rejects_negative_budget(proxy_client, prisma, scratch, world):
|
|
"""Input-validation pin: max_budget < 0 is a 400, no row created."""
|
|
resp = await proxy_client.post(
|
|
"/team/new",
|
|
headers={"Authorization": f"Bearer {world.keys[Actor.PROXY_ADMIN].cleartext}"},
|
|
json={"team_id": scratch.prefix, "max_budget": -1},
|
|
)
|
|
assert resp.status_code == 400, resp.text
|
|
row = await prisma.db.litellm_teamtable.find_unique(where={"team_id": scratch.prefix})
|
|
assert row is None
|
|
|
|
|
|
async def test_team_new_rejects_duplicate_team_id(proxy_client, prisma, scratch, world):
|
|
"""Input-validation pin: a colliding team_id is a 400 on the second call."""
|
|
seeder = world.keys[Actor.PROXY_ADMIN].cleartext
|
|
first = await proxy_client.post(
|
|
"/team/new",
|
|
headers={"Authorization": f"Bearer {seeder}"},
|
|
json={"team_id": scratch.prefix, "team_alias": scratch.prefix},
|
|
)
|
|
assert first.status_code == 200, first.text
|
|
|
|
second = await proxy_client.post(
|
|
"/team/new",
|
|
headers={"Authorization": f"Bearer {seeder}"},
|
|
json={"team_id": scratch.prefix, "team_alias": scratch.prefix},
|
|
)
|
|
assert second.status_code == 400, second.text
|
|
|
|
|
|
async def test_team_new_unknown_organization_is_400(proxy_client, prisma, scratch, world):
|
|
"""A /team/new with an organization_id that does not exist fails 400:
|
|
OrganizationNotFoundError is routed into the handler's own
|
|
'Organization not found' guard instead of escaping as a 500."""
|
|
resp = await proxy_client.post(
|
|
"/team/new",
|
|
headers={"Authorization": f"Bearer {world.keys[Actor.PROXY_ADMIN].cleartext}"},
|
|
json={
|
|
"team_id": scratch.prefix,
|
|
"organization_id": scratch.tag("no-such-org"),
|
|
},
|
|
)
|
|
assert resp.status_code == 400, resp.text
|
|
assert "Organization not found" in resp.text
|
|
row = await prisma.db.litellm_teamtable.find_unique(where={"team_id": scratch.prefix})
|
|
assert row is None
|