mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
fix(proxy): refuse a teamless native-client grant for a user who has teams
The consent page offers the team picker, but a form posted without a team sealed a teamless grant and the token endpoint minted an unscoped credential for a team member, escaping the team attribution classic lite login always applies. The minter now refuses such a grant on redemption and refresh alike; memberships whose team rows are gone still count as no team so they cannot lock a user out
This commit is contained in:
parent
c011b4b56f
commit
17455f4086
4 changed files with 30 additions and 8 deletions
|
|
@ -154,7 +154,7 @@ PROXY_API_AUDIENCE: Final[SessionAudience] = "proxy_api"
|
|||
proxy base URL itself as the RFC 8707 ``resource``: the grant then mints the proxy-API CLI
|
||||
credential that LLM routes accept, instead of the MCP-only session pair."""
|
||||
|
||||
ProxyCredentialMintFailure = Literal[ReloadUserFailure, "not_a_member"]
|
||||
ProxyCredentialMintFailure = Literal[ReloadUserFailure, "not_a_member", "team_required"]
|
||||
|
||||
|
||||
class MintedProxyCredential(BaseModel):
|
||||
|
|
@ -167,7 +167,8 @@ class MintedProxyCredential(BaseModel):
|
|||
|
||||
class MintProxyCredential(Protocol):
|
||||
"""Injected proxy-API credential minter ``(user_id, team_id)``: reloads the user live,
|
||||
checks team membership, and mints the same credential ``lite login`` mints."""
|
||||
checks team membership, refuses a teamless grant for a user who has teams to pick from,
|
||||
and mints the same credential ``lite login`` mints."""
|
||||
|
||||
def __call__(
|
||||
self, user_id: str, team_id: str | None, /
|
||||
|
|
@ -924,6 +925,10 @@ def _mint_failure_response(failure: ProxyCredentialMintFailure) -> Response:
|
|||
return _oauth_error(
|
||||
400, "invalid_grant", "the user is no longer a member of the team this grant was issued for"
|
||||
)
|
||||
case "team_required":
|
||||
return _oauth_error(
|
||||
400, "invalid_grant", "this user belongs to a team; sign in again and pick the team for this credential"
|
||||
)
|
||||
case "unavailable" | "unresolvable" | "no_active_key":
|
||||
return _reload_failure_response(failure)
|
||||
case _:
|
||||
|
|
|
|||
|
|
@ -45,7 +45,11 @@ async def mint_proxy_credential(
|
|||
live, so a team the user left between consent and redemption (or between refreshes)
|
||||
refuses the grant instead of minting a credential attributed to a team they are no
|
||||
longer on. The team is exactly the one the consent page sealed into the grant; nothing
|
||||
is picked on the user's behalf here, so a refresh can never move the credential. The
|
||||
is picked on the user's behalf here, so a refresh can never move the credential, and a
|
||||
grant that names no team is refused for a user with a live team to pick from (the same
|
||||
rule ``lite login`` applies), so a user cannot step outside their teams' attribution by
|
||||
posting the consent form without one. Memberships whose team rows are gone count as no
|
||||
team at all, the way ``lite login`` treats them, so they can never lock a user out. The
|
||||
user row handed to the minter carries no team list, exactly like ``lite login``'s, so
|
||||
the minter's own first-team fallback stays inert."""
|
||||
user: Final = await load_active_user_by_id(user_id)
|
||||
|
|
@ -55,9 +59,11 @@ async def mint_proxy_credential(
|
|||
return "no_active_key"
|
||||
if team_id is not None and team_id not in user.teams:
|
||||
return "not_a_member"
|
||||
details: Final = await _team_details(user.teams) if team_id is not None else ()
|
||||
details: Final = await _team_details(user.teams) if user.teams else ()
|
||||
if details is None:
|
||||
return "unavailable"
|
||||
if team_id is None and any(detail.team_id is not None for detail in details):
|
||||
return "team_required"
|
||||
selected: Final = selected_cli_sso_team_detail(details, team_id)
|
||||
if selected is None:
|
||||
return "not_a_member"
|
||||
|
|
|
|||
|
|
@ -1414,6 +1414,7 @@ async def test_native_code_without_a_minter_is_refused_server_side():
|
|||
"failure, status, error",
|
||||
[
|
||||
("not_a_member", 400, "invalid_grant"),
|
||||
("team_required", 400, "invalid_grant"),
|
||||
("no_active_key", 400, "invalid_grant"),
|
||||
("unavailable", 503, "temporarily_unavailable"),
|
||||
("unresolvable", 500, "server_error"),
|
||||
|
|
|
|||
|
|
@ -74,16 +74,26 @@ async def test_mint_refuses_a_user_without_a_role(load_user, fetch_teams):
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mint_without_a_chosen_team_never_picks_one_for_a_team_member(load_user, fetch_teams):
|
||||
async def test_mint_refuses_a_teamless_grant_for_a_team_member(load_user, fetch_teams):
|
||||
"""The consent page is the only place a team gets chosen, so a grant sealed without one
|
||||
stays unscoped on every redemption and refresh instead of drifting onto the first team."""
|
||||
is refused for a user with teams instead of minting an unscoped credential or drifting
|
||||
onto the first team, on redemption and on every refresh alike."""
|
||||
assert await mint_proxy_credential("u1", None) == "team_required"
|
||||
load_user.assert_awaited_once_with("u1")
|
||||
fetch_teams.assert_awaited_once_with(ANY, ["team-a", "team-b"])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mint_for_a_member_of_only_deleted_teams_is_unscoped(load_user, fetch_teams):
|
||||
"""Memberships whose team rows are gone offer nothing to pick, so they never lock the
|
||||
user out of signing in."""
|
||||
load_user.return_value = _user(teams=["team-gone"])
|
||||
fetch_teams.return_value = ()
|
||||
minted = await mint_proxy_credential("u1", None)
|
||||
assert isinstance(minted, MintedProxyCredential)
|
||||
assert minted.user_id == "u1"
|
||||
assert minted.team_id is None
|
||||
assert minted.expires_in == CLI_JWT_EXPIRATION_HOURS * 3600
|
||||
load_user.assert_awaited_once_with("u1")
|
||||
fetch_teams.assert_not_awaited()
|
||||
decoded = _decoded(minted)
|
||||
assert decoded.user_id == "u1"
|
||||
assert decoded.team_id is None
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue