mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-25 01:02:15 +00:00
fix(jwt): say x-litellm-team-id matched no team id or alias in the 403 (#42495)
* fix(jwt): say x-litellm-team-id matched no team id or alias in the 403 Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(jwt): tell the caller when x-litellm-team-id names an alias shared by several teams Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(jwt): deny a shared x-litellm-team-id alias exactly like an unknown value A distinct 403 for an alias several teams share was raised before the allowed-teams check, so any JWT could probe which aliases exist. The alias lookup now treats the duplicate as a miss, and both denials say the value does not resolve to a team id or a unique team alias, which is true for unknown, unauthorized and duplicate values alike Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: ryan <ryan@berri.ai> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
parent
d47e72f66c
commit
08639fcf42
2 changed files with 50 additions and 12 deletions
|
|
@ -1888,7 +1888,10 @@ class JWTAuthManager:
|
|||
def _raise_header_team_not_allowed(header_value: str, allowed_team_ids: set[str]) -> NoReturn:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=f"Team '{header_value}' from x-litellm-team-id header is not in your JWT's allowed teams. Allowed teams: {list(allowed_team_ids)}",
|
||||
detail=(
|
||||
f"x-litellm-team-id '{header_value}' does not resolve to a team id or a unique team alias in your "
|
||||
f"JWT's allowed teams. Allowed team ids: {sorted(allowed_team_ids)}"
|
||||
),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
|
|
@ -1937,8 +1940,9 @@ class JWTAuthManager:
|
|||
Raises:
|
||||
HTTPException: 403 when neither the value nor the team it aliases is
|
||||
an allowed team, or the DB fallback's membership denial when the
|
||||
value names no team at all; a 5xx from the alias lookup itself
|
||||
is re-raised rather than reported as a denial
|
||||
value names no team at all; an alias several teams share resolves
|
||||
to no team and is denied like an unknown value; a 5xx from the
|
||||
alias lookup itself is re-raised rather than reported as a denial
|
||||
"""
|
||||
header_value: Final = JWTAuthManager._team_header_value(request_headers)
|
||||
if not header_value:
|
||||
|
|
@ -2336,7 +2340,10 @@ class JWTAuthManager:
|
|||
"""
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=(f"Team '{header_value}' (from x-litellm-team-id header) is not in your team memberships."),
|
||||
detail=(
|
||||
f"x-litellm-team-id '{header_value}' does not resolve to a team id or a unique team alias among your "
|
||||
"team memberships."
|
||||
),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
|
|
|
|||
|
|
@ -7045,7 +7045,9 @@ async def test_resolve_team_from_header_denies_aliases_of_teams_the_jwt_does_not
|
|||
"""An alias that exists but names a team outside the JWT's allowed teams is
|
||||
refused with the same 403 as an unknown value, and the detail names only
|
||||
what the caller sent, so the response reveals neither that the alias exists
|
||||
nor which team id it maps to."""
|
||||
nor which team id it maps to. Because the id and the alias lookups both ran
|
||||
before the denial, the detail says the value resolved to neither form and
|
||||
lists the team ids the JWT does allow."""
|
||||
aliases = {"alias_a": "team_a", "alias_b": "team_b"}
|
||||
|
||||
with pytest.raises(HTTPException) as other_team:
|
||||
|
|
@ -7057,6 +7059,10 @@ async def test_resolve_team_from_header_denies_aliases_of_teams_the_jwt_does_not
|
|||
assert unknown.value.status_code == 403
|
||||
assert "team_b" not in other_team.value.detail
|
||||
assert other_team.value.detail.replace("alias_b", "<value>") == unknown.value.detail.replace("no_such", "<value>")
|
||||
assert unknown.value.detail == (
|
||||
"x-litellm-team-id 'no_such' does not resolve to a team id or a unique team alias in your JWT's allowed "
|
||||
"teams. Allowed team ids: ['team_a']"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -7080,7 +7086,9 @@ async def test_resolve_team_from_header_under_db_fallback_tries_the_id_before_th
|
|||
with pytest.raises(HTTPException) as neither:
|
||||
await _resolve_header("ghost", set(), True, _teams_by_id(known_ids), _teams_by_alias(aliases))
|
||||
assert neither.value.status_code == 403
|
||||
assert neither.value.detail == ("Team 'ghost' (from x-litellm-team-id header) is not in your team memberships.")
|
||||
assert neither.value.detail == (
|
||||
"x-litellm-team-id 'ghost' does not resolve to a team id or a unique team alias among your team memberships."
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -7112,15 +7120,20 @@ async def test_resolve_team_from_header_under_db_fallback_never_aliases_a_team_i
|
|||
)
|
||||
|
||||
assert unreadable.value.status_code == 403
|
||||
assert unreadable.value.detail == ("Team 'team_a' (from x-litellm-team-id header) is not in your team memberships.")
|
||||
assert unreadable.value.detail == (
|
||||
"x-litellm-team-id 'team_a' does not resolve to a team id or a unique team alias among your team memberships."
|
||||
)
|
||||
lookups_by_alias.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_team_from_header_treats_a_duplicate_alias_as_no_match_but_surfaces_lookup_errors():
|
||||
"""An alias two teams share cannot name one team, so it is refused like an
|
||||
unknown value (a 4xx from the lookup is a miss), while a lookup failure
|
||||
(5xx) is not disguised as a denial and propagates as is."""
|
||||
async def test_resolve_team_from_header_denies_a_duplicate_alias_like_an_unknown_value_but_surfaces_lookup_errors():
|
||||
"""An alias two teams share resolves to no single team, so it is denied with
|
||||
the very 403 an unknown value gets, under claims and under the DB fallback
|
||||
alike: the caller cannot be checked against either team, so telling it the
|
||||
alias is shared would let any JWT probe which aliases exist. The detail says
|
||||
the value resolved to no team id or unique alias, which is true for both.
|
||||
A lookup failure (5xx) is not disguised as a denial and propagates as is."""
|
||||
|
||||
async def duplicate_alias(team_alias, **kwargs):
|
||||
raise HTTPException(status_code=400, detail={"error": f"Multiple teams found with alias '{team_alias}'."})
|
||||
|
|
@ -7130,8 +7143,26 @@ async def test_resolve_team_from_header_treats_a_duplicate_alias_as_no_match_but
|
|||
|
||||
with pytest.raises(HTTPException) as duplicate:
|
||||
await _resolve_header("shared_alias", {"team_a"}, False, _team_lookup_404, duplicate_alias)
|
||||
with pytest.raises(HTTPException) as unknown:
|
||||
await _resolve_header("shared_alias", {"team_a"}, False, _team_lookup_404, _team_alias_lookup_404)
|
||||
assert duplicate.value.status_code == 403
|
||||
assert "Multiple teams" not in str(duplicate.value.detail)
|
||||
assert duplicate.value.detail == unknown.value.detail
|
||||
assert duplicate.value.detail == (
|
||||
"x-litellm-team-id 'shared_alias' does not resolve to a team id or a unique team alias in your JWT's "
|
||||
"allowed teams. Allowed team ids: ['team_a']"
|
||||
)
|
||||
|
||||
known_ids = frozenset({"team_a"})
|
||||
with pytest.raises(HTTPException) as duplicate_under_fallback:
|
||||
await _resolve_header("shared_alias", set(), True, _teams_by_id(known_ids), duplicate_alias)
|
||||
with pytest.raises(HTTPException) as unknown_under_fallback:
|
||||
await _resolve_header("shared_alias", set(), True, _teams_by_id(known_ids), _team_alias_lookup_404)
|
||||
assert duplicate_under_fallback.value.status_code == 403
|
||||
assert duplicate_under_fallback.value.detail == unknown_under_fallback.value.detail
|
||||
assert duplicate_under_fallback.value.detail == (
|
||||
"x-litellm-team-id 'shared_alias' does not resolve to a team id or a unique team alias among your team "
|
||||
"memberships."
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as failure:
|
||||
await _resolve_header("alias_a", {"team_a"}, False, _team_lookup_404, db_down)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue