Coverage for .venv/lib/python3.13/site-packages/litellm/proxy/auth/resolvers/grants.py: 48%
110 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 12:01 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 12:01 +0000
1"""Load a caller's user row, team row, and team membership from the database and validate them together.
3The virtual-key path reads these off the combined-view SQL join. Every other credential (an IdP JWT, a
4``lite login`` session token) carries only identifiers, or a snapshot of grants taken when it was minted, so
5it has to read the live rows on each request. Both of those paths resolve the same rows with the same
6membership rule, and ``GrantResolver`` is the one place that rule lives.
7"""
9from __future__ import annotations
11from collections.abc import Coroutine, Mapping
12from dataclasses import dataclass
13from types import MappingProxyType
14from typing import TYPE_CHECKING, Final, NoReturn, Protocol, TypeAlias
16from fastapi import HTTPException, status
17from pydantic import BaseModel, ValidationError
18from pydantic.main import IncEx
19from typing_extensions import assert_never
21from litellm._logging import verbose_proxy_logger
22from litellm.proxy._types import (
23 LiteLLM_TeamMembership,
24 LiteLLM_TeamTableCachedObj,
25 LiteLLM_UserTable,
26 ProxyErrorTypes,
27 ProxyException,
28)
29from litellm.proxy.auth.auth_checks import (
30 TeamNotFoundError,
31 get_team_membership,
32 get_team_object,
33 get_user_object,
34)
35from litellm.types.proxy.auth.auth_checks import UserNotFoundError
37if TYPE_CHECKING: 37 ↛ 38line 37 didn't jump to line 38 because the condition on line 37 was never true
38 from litellm.proxy._types import Span
39 from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
40 from litellm.proxy.utils import PrismaClient, ProxyLogging
43class UserLoader(Protocol):
44 def __call__( 44 ↛ exitline 44 didn't return from function '__call__' because
45 self,
46 *,
47 user_id: str | None,
48 prisma_client: PrismaClient | None,
49 user_api_key_cache: UserApiKeyCache,
50 user_id_upsert: bool,
51 parent_otel_span: Span | None,
52 proxy_logging_obj: ProxyLogging | None,
53 sso_user_id: str | None,
54 user_email: str | None,
55 ) -> Coroutine[object, object, LiteLLM_UserTable | None]: ...
58class TeamLoader(Protocol):
59 def __call__( 59 ↛ exitline 59 didn't return from function '__call__' because
60 self,
61 *,
62 team_id: str,
63 prisma_client: PrismaClient | None,
64 user_api_key_cache: UserApiKeyCache,
65 parent_otel_span: Span | None,
66 proxy_logging_obj: ProxyLogging | None,
67 ) -> Coroutine[object, object, LiteLLM_TeamTableCachedObj]: ...
70class MembershipLoader(Protocol):
71 def __call__( 71 ↛ exitline 71 didn't return from function '__call__' because
72 self,
73 *,
74 user_id: str,
75 team_id: str,
76 prisma_client: PrismaClient | None,
77 user_api_key_cache: UserApiKeyCache,
78 parent_otel_span: Span | None,
79 proxy_logging_obj: ProxyLogging | None,
80 ) -> Coroutine[object, object, LiteLLM_TeamMembership | None]: ...
83@dataclass(frozen=True, slots=True)
84class UserLookup:
85 """The user a credential names, plus the hints ``get_user_object`` may fall back to when the id alone
86 matches no row."""
88 user_id: str | None
89 user_email: str | None = None
90 sso_user_id: str | None = None
91 upsert: bool = False
94@dataclass(frozen=True, slots=True)
95class ResolvedGrants:
96 """The live rows behind a credential. ``effective_user_id`` is the DB row's id when a fuzzy match found a
97 legacy row under a different id (GH #26789), otherwise the id the credential named."""
99 user_object: LiteLLM_UserTable | None
100 team_object: LiteLLM_TeamTableCachedObj | None
101 team_membership: LiteLLM_TeamMembership | None
102 effective_user_id: str | None
105@dataclass(frozen=True, slots=True)
106class UserGone:
107 user_id: str
110@dataclass(frozen=True, slots=True)
111class TeamGone:
112 team_id: str
115@dataclass(frozen=True, slots=True)
116class NotAMember:
117 user_id: str
118 team_id: str
121@dataclass(frozen=True, slots=True)
122class LookupDegraded:
123 """A row could not be read for a reason that says nothing about the caller: the database is down or a
124 loader failed. The caller decides whether a grant it already holds may stand in."""
126 error: Exception
129GrantDenial: TypeAlias = UserGone | TeamGone | NotAMember
130GrantOutcome: TypeAlias = ResolvedGrants | GrantDenial | LookupDegraded
133_MODELS_COLUMN: Final[Mapping[str, IncEx | bool]] = MappingProxyType({"models": True})
136class _UserModelColumn(BaseModel):
137 """``LiteLLM_UserTable.models`` is a bare ``list``; re-read it with the shape a token's ``models`` takes."""
139 models: tuple[str, ...] = ()
142def user_models(user_object: LiteLLM_UserTable) -> tuple[str, ...]:
143 try:
144 return _UserModelColumn.model_validate(user_object.model_dump(include=_MODELS_COLUMN)).models
145 except ValidationError:
146 return ()
149def canonical_user_id(user_id: str | None, user_object: LiteLLM_UserTable | None) -> str | None:
150 if user_object is not None and user_object.user_id:
151 return user_object.user_id
152 return user_id
155def raise_public(denial: GrantDenial) -> NoReturn:
156 match denial:
157 case UserGone(user_id=user_id):
158 raise ProxyException(
159 message=f"Authentication Error, user '{user_id}' no longer exists.",
160 type=ProxyErrorTypes.auth_error,
161 param="user_id",
162 code=status.HTTP_401_UNAUTHORIZED,
163 )
164 case TeamGone(team_id=team_id):
165 raise TeamNotFoundError(team_id=team_id)
166 case NotAMember(team_id=team_id):
167 raise HTTPException(
168 status_code=status.HTTP_403_FORBIDDEN,
169 detail=f"Team '{team_id}' is not in your team memberships.",
170 )
171 case _:
172 assert_never(denial)
175class GrantResolver:
176 """Reads the user, membership, and team rows for a credential through injected loaders.
178 The loaders default to the shared ``auth_checks`` readers. A caller passes its own module's names for them
179 so the reads stay interceptable where that module's callers already intercept them. ``resolve_identity``
180 is the JWT half: user and membership only, since the JWT builder selects the team itself and lets loader
181 errors surface as they are. ``resolve`` also reads the team row and applies the membership rule, which is
182 what a credential carrying a grant snapshot needs to refresh it.
183 """
185 def __init__(
186 self,
187 prisma_client: PrismaClient | None,
188 cache: UserApiKeyCache,
189 *,
190 parent_otel_span: Span | None = None,
191 proxy_logging_obj: ProxyLogging | None = None,
192 load_user: UserLoader = get_user_object,
193 load_team: TeamLoader = get_team_object,
194 load_membership: MembershipLoader = get_team_membership,
195 ) -> None:
196 self._prisma = prisma_client
197 self._cache = cache
198 self._parent_otel_span = parent_otel_span
199 self._proxy_logging_obj = proxy_logging_obj
200 self._load_user = load_user
201 self._load_team = load_team
202 self._load_membership = load_membership
204 async def resolve_identity(
205 self, lookup: UserLookup, team_id: str | None
206 ) -> tuple[LiteLLM_UserTable | None, LiteLLM_TeamMembership | None, str | None]:
207 user_object: Final = await self._user(lookup) if lookup.user_id else None
208 effective_user_id: Final = canonical_user_id(lookup.user_id, user_object)
209 if effective_user_id != lookup.user_id:
210 verbose_proxy_logger.debug(
211 "Auth: rebinding user_id %r -> DB user_id %r (email/sso match)",
212 lookup.user_id,
213 effective_user_id,
214 )
215 membership: Final = (
216 await self._membership(user_id=effective_user_id, team_id=team_id)
217 if effective_user_id and team_id
218 else None
219 )
220 return user_object, membership, effective_user_id
222 async def resolve(self, lookup: UserLookup, team_id: str | None) -> GrantOutcome:
223 try:
224 user_object, membership, effective_user_id = await self.resolve_identity(lookup, team_id)
225 except UserNotFoundError:
226 return UserGone(user_id=lookup.user_id or "")
227 except Exception as error:
228 return LookupDegraded(error=error)
229 if team_id is None:
230 return ResolvedGrants(user_object, None, membership, effective_user_id)
231 if user_object is not None and team_id not in user_object.teams:
232 return NotAMember(user_id=user_object.user_id, team_id=team_id)
233 try:
234 team_object: Final = await self._load_team(
235 team_id=team_id,
236 prisma_client=self._prisma,
237 user_api_key_cache=self._cache,
238 parent_otel_span=self._parent_otel_span,
239 proxy_logging_obj=self._proxy_logging_obj,
240 )
241 except TeamNotFoundError:
242 return TeamGone(team_id=team_id)
243 except Exception as error:
244 return LookupDegraded(error=error)
245 return ResolvedGrants(user_object, team_object, membership, effective_user_id)
247 async def _user(self, lookup: UserLookup) -> LiteLLM_UserTable | None:
248 return await self._load_user(
249 user_id=lookup.user_id,
250 prisma_client=self._prisma,
251 user_api_key_cache=self._cache,
252 user_id_upsert=lookup.upsert,
253 parent_otel_span=self._parent_otel_span,
254 proxy_logging_obj=self._proxy_logging_obj,
255 user_email=lookup.user_email,
256 sso_user_id=lookup.sso_user_id,
257 )
259 async def _membership(self, user_id: str, team_id: str) -> LiteLLM_TeamMembership | None:
260 return await self._load_membership(
261 user_id=user_id,
262 team_id=team_id,
263 prisma_client=self._prisma,
264 user_api_key_cache=self._cache,
265 parent_otel_span=self._parent_otel_span,
266 proxy_logging_obj=self._proxy_logging_obj,
267 )