Coverage for .venv/lib/python3.13/site-packages/litellm/proxy/agent_endpoints/auth/agent_permission_handler.py: 34%
232 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"""
2Agent Permission Handler for LiteLLM Proxy.
4Handles agent permission checking for keys and teams using object_permission_id.
5Follows the same pattern as MCP permission handling.
6"""
8import asyncio
9from collections.abc import Awaitable, Callable, Sequence
10from dataclasses import dataclass
11from typing import Final, TypeAlias
13from litellm._logging import verbose_logger
14from litellm.proxy._experimental.mcp_server.ui_session_utils import build_effective_auth_contexts
15from litellm.proxy._types import (
16 UI_TEAM_ID,
17 LiteLLM_ObjectPermissionTable,
18 LiteLLM_TeamTable,
19 LitellmUserRoles,
20 UserAPIKeyAuth,
21)
22from litellm.proxy.agent_endpoints.auth.agent_access_groups import (
23 CeilingResolver,
24 resolve_agent_access_group_ceiling,
25)
26from litellm.proxy.agent_endpoints.auth.agent_caller import agent_caller_auth
27from litellm.repositories.table_repositories import AgentsRepository
28from litellm.types.agents import AgentResponse
31@dataclass(frozen=True, slots=True)
32class UnrestrictedAgentAccess:
33 """No agent grant exists on the key or its team, so every agent is reachable."""
36@dataclass(frozen=True, slots=True)
37class RestrictedAgentAccess:
38 """Only ``agent_ids`` are reachable. An empty set denies every agent."""
40 agent_ids: frozenset[str]
43AgentAccess: TypeAlias = UnrestrictedAgentAccess | RestrictedAgentAccess
46def _to_stable_ids(agent_ids: frozenset[str]) -> frozenset[str]:
47 from litellm.proxy.agent_endpoints.agent_registry import global_agent_registry
49 return frozenset(global_agent_registry.stable_agent_id(agent_id) for agent_id in agent_ids)
52def _restricted_ids(access: AgentAccess) -> frozenset[str] | None:
53 if isinstance(access, UnrestrictedAgentAccess): 53 ↛ 55line 53 didn't jump to line 55 because the condition on line 53 was always true
54 return None
55 return _to_stable_ids(access.agent_ids)
58def _intersect_agent_access(key_access: AgentAccess, team_access: AgentAccess) -> AgentAccess:
59 key_ids: Final = _restricted_ids(key_access)
60 team_ids: Final = _restricted_ids(team_access)
61 if key_ids is None: 61 ↛ 63line 61 didn't jump to line 63 because the condition on line 61 was always true
62 return UnrestrictedAgentAccess() if team_ids is None else RestrictedAgentAccess(team_ids)
63 if team_ids is None:
64 return RestrictedAgentAccess(key_ids)
65 return RestrictedAgentAccess(key_ids & team_ids)
68class AgentRequestHandler:
69 """
70 Class to handle agent permission checking, including:
71 1. Key-level agent permissions
72 2. Team-level agent permissions
73 3. Agent access group resolution
75 Follows the same inheritance logic as MCP:
76 - If team has restrictions and key has restrictions: use intersection
77 - If team has restrictions and key has none: inherit from team
78 - If team has no restrictions: use key restrictions
79 - If no restrictions: allow all agents
80 """
82 @staticmethod
83 async def resolve_agent_access(
84 user_api_key_auth: UserAPIKeyAuth | None = None,
85 resolve_ceiling: CeilingResolver = resolve_agent_access_group_ceiling,
86 ) -> AgentAccess:
87 """Agents the key may reach: key and team grants, intersected with the agent's access group ceiling
88 and, for an agent key acting on behalf of an invoking user, with that user's team grants."""
89 key_team_access: Final = await AgentRequestHandler._resolve_key_team_agent_access(user_api_key_auth)
90 caller_access: Final = await AgentRequestHandler._agent_caller_access(user_api_key_auth)
91 own_access: Final = _intersect_agent_access(key_team_access, caller_access)
92 agent_ceiling: Final = await AgentRequestHandler._agent_access_group_ceiling(user_api_key_auth, resolve_ceiling)
93 if agent_ceiling is None: 93 ↛ 95line 93 didn't jump to line 95 because the condition on line 93 was always true
94 return own_access
95 if isinstance(own_access, UnrestrictedAgentAccess):
96 return RestrictedAgentAccess(agent_ceiling)
97 return RestrictedAgentAccess(own_access.agent_ids & agent_ceiling)
99 @staticmethod
100 async def _agent_caller_access(user_api_key_auth: UserAPIKeyAuth | None) -> AgentAccess:
101 caller_auth: Final = agent_caller_auth(user_api_key_auth) if user_api_key_auth else None
102 if caller_auth is None: 102 ↛ 104line 102 didn't jump to line 104 because the condition on line 102 was always true
103 return UnrestrictedAgentAccess()
104 return await AgentRequestHandler._get_allowed_agents_for_team(caller_auth)
106 @staticmethod
107 async def _resolve_key_team_agent_access(
108 user_api_key_auth: UserAPIKeyAuth | None,
109 ) -> AgentAccess:
110 try:
111 key_access: Final = await AgentRequestHandler._get_allowed_agents_for_key(user_api_key_auth)
112 team_access: Final = await AgentRequestHandler._get_allowed_agents_for_team(user_api_key_auth)
113 except Exception as e:
114 verbose_logger.warning("Failed to get allowed agents: %s", e)
115 return UnrestrictedAgentAccess()
116 return _intersect_agent_access(key_access, team_access)
118 @staticmethod
119 async def _agent_access_group_ceiling(
120 user_api_key_auth: UserAPIKeyAuth | None,
121 resolve_ceiling: CeilingResolver,
122 ) -> frozenset[str] | None:
123 if user_api_key_auth is None or not user_api_key_auth.agent_id: 123 ↛ 125line 123 didn't jump to line 125 because the condition on line 123 was always true
124 return None
125 ceiling: Final = await resolve_ceiling(user_api_key_auth.agent_id)
126 if ceiling is None:
127 return None
128 return _to_stable_ids(ceiling.agent_ids)
130 @staticmethod
131 async def is_agent_allowed(
132 agent_id: str,
133 user_api_key_auth: UserAPIKeyAuth | None = None,
134 resolve_ceiling: CeilingResolver = resolve_agent_access_group_ceiling,
135 ) -> bool:
136 """
137 Check if a specific agent is allowed for the given user/key.
139 Args:
140 agent_id: The agent ID to check
141 user_api_key_auth: User authentication info
143 Returns:
144 bool: True if agent is allowed, False otherwise
145 """
146 from litellm.proxy.agent_endpoints.agent_registry import global_agent_registry
148 match await AgentRequestHandler.resolve_agent_access(user_api_key_auth, resolve_ceiling):
149 case UnrestrictedAgentAccess():
150 return True
151 case RestrictedAgentAccess(allowed_agent_ids):
152 stable_id: Final = global_agent_registry.stable_agent_id(agent_id)
153 return not global_agent_registry.ids_for_agent(stable_id).isdisjoint(allowed_agent_ids)
155 @staticmethod
156 def _get_key_object_permission(
157 user_api_key_auth: UserAPIKeyAuth | None = None,
158 ) -> LiteLLM_ObjectPermissionTable | None:
159 """
160 Get key object_permission - already loaded by get_key_object() in main auth flow.
162 Note: object_permission is automatically populated when the key is fetched via
163 get_key_object() in litellm/proxy/auth/auth_checks.py
164 """
165 if not user_api_key_auth: 165 ↛ 166line 165 didn't jump to line 166 because the condition on line 165 was never true
166 return None
168 return user_api_key_auth.object_permission
170 @staticmethod
171 async def _get_team_object_permission(
172 user_api_key_auth: UserAPIKeyAuth | None = None,
173 ) -> LiteLLM_ObjectPermissionTable | None:
174 """
175 Get team object_permission - automatically loaded by get_team_object() in main auth flow.
177 Note: object_permission is automatically populated when the team is fetched via
178 get_team_object() in litellm/proxy/auth/auth_checks.py
179 """
180 from litellm.proxy.auth.auth_checks import get_team_object
181 from litellm.proxy.proxy_server import (
182 prisma_client,
183 proxy_logging_obj,
184 user_api_key_cache,
185 )
187 if not user_api_key_auth or not user_api_key_auth.team_id or not prisma_client:
188 return None
190 # Get the team object (which has object_permission already loaded)
191 team_obj: Final[LiteLLM_TeamTable | None] = await get_team_object(
192 team_id=user_api_key_auth.team_id,
193 prisma_client=prisma_client,
194 user_api_key_cache=user_api_key_cache,
195 parent_otel_span=user_api_key_auth.parent_otel_span,
196 proxy_logging_obj=proxy_logging_obj,
197 )
199 if not team_obj:
200 return None
202 return team_obj.object_permission
204 @staticmethod
205 async def _get_allowed_agents_for_key(
206 user_api_key_auth: UserAPIKeyAuth | None = None,
207 ) -> AgentAccess:
208 """
209 Get allowed agents for a key.
211 1. First checks native key-level agent permissions (object_permission)
212 2. Also includes agents from key's access_group_ids (unified access groups)
214 A key that declares agents or access groups is restricted even when those
215 declarations resolve to nothing, so an emptied or deleted access group denies
216 rather than opening the key up. Lookup failures still propagate to the caller,
217 which keeps them fail-open.
219 Note: object_permission is already loaded by get_key_object() in main auth flow.
220 """
221 if user_api_key_auth is None: 221 ↛ 222line 221 didn't jump to line 222 because the condition on line 221 was never true
222 return UnrestrictedAgentAccess()
224 try:
225 # 1. Get agents from object_permission (native permissions)
226 key_object_permission: Final = AgentRequestHandler._get_key_object_permission(user_api_key_auth)
227 direct_agents: Final = tuple(
228 key_object_permission.agents or () if key_object_permission is not None else ()
229 )
230 declared_access_groups: Final = tuple(
231 key_object_permission.agent_access_groups or () if key_object_permission is not None else ()
232 )
233 # 2. Fallback: get agent IDs from key's access_group_ids (unified access groups)
234 key_access_group_ids: Final = tuple(user_api_key_auth.access_group_ids or ())
236 if not direct_agents and not declared_access_groups and not key_access_group_ids: 236 ↛ 239line 236 didn't jump to line 239 because the condition on line 236 was always true
237 return UnrestrictedAgentAccess()
239 access_group_agents: Final = (
240 tuple(await AgentRequestHandler._get_agents_from_access_groups(list(declared_access_groups)))
241 if declared_access_groups
242 else ()
243 )
244 unified_agents: Final = (
245 tuple(await AgentRequestHandler._get_unified_access_group_agents(list(key_access_group_ids)))
246 if key_access_group_ids
247 else ()
248 )
250 return RestrictedAgentAccess(frozenset(direct_agents + access_group_agents + unified_agents))
251 except Exception as e:
252 verbose_logger.warning("Failed to get allowed agents for key: %s", e)
253 return UnrestrictedAgentAccess()
255 @staticmethod
256 async def _get_allowed_agents_for_team(
257 user_api_key_auth: UserAPIKeyAuth | None = None,
258 ) -> AgentAccess:
259 """
260 Get allowed agents for a team.
262 1. First checks native team-level agent permissions (object_permission)
263 2. Also includes agents from team's access_group_ids (unified access groups)
265 Fetches the team object once and reuses it for both permission sources.
266 Declared-but-empty grants stay restricted; see `_get_allowed_agents_for_key`.
267 """
268 if user_api_key_auth is None: 268 ↛ 269line 268 didn't jump to line 269 because the condition on line 268 was never true
269 return UnrestrictedAgentAccess()
271 if user_api_key_auth.team_id is None: 271 ↛ 274line 271 didn't jump to line 274 because the condition on line 271 was always true
272 return UnrestrictedAgentAccess()
274 try:
275 from litellm.proxy.auth.auth_checks import get_team_object
276 from litellm.proxy.proxy_server import (
277 prisma_client,
278 proxy_logging_obj,
279 user_api_key_cache,
280 )
282 if not prisma_client:
283 return UnrestrictedAgentAccess()
285 # Fetch the team object once for both permission sources
286 team_obj: Final = await get_team_object(
287 team_id=user_api_key_auth.team_id,
288 prisma_client=prisma_client,
289 user_api_key_cache=user_api_key_cache,
290 parent_otel_span=user_api_key_auth.parent_otel_span,
291 proxy_logging_obj=proxy_logging_obj,
292 )
294 if team_obj is None:
295 return UnrestrictedAgentAccess()
297 # 1. Get agents from object_permission (native permissions)
298 object_permissions: Final = team_obj.object_permission
299 direct_agents: Final = tuple(object_permissions.agents or () if object_permissions is not None else ())
300 declared_access_groups: Final = tuple(
301 object_permissions.agent_access_groups or () if object_permissions is not None else ()
302 )
303 # 2. Also include agents from team's access_group_ids (unified access groups)
304 team_access_group_ids: Final = tuple(team_obj.access_group_ids or ())
306 if not direct_agents and not declared_access_groups and not team_access_group_ids:
307 return UnrestrictedAgentAccess()
309 access_group_agents: Final = (
310 tuple(await AgentRequestHandler._get_agents_from_access_groups(list(declared_access_groups)))
311 if declared_access_groups
312 else ()
313 )
314 unified_agents: Final = (
315 tuple(await AgentRequestHandler._get_unified_access_group_agents(list(team_access_group_ids)))
316 if team_access_group_ids
317 else ()
318 )
320 return RestrictedAgentAccess(frozenset(direct_agents + access_group_agents + unified_agents))
321 except Exception as e:
322 # litellm-dashboard is the default UI team and will never have agents;
323 # skip noisy warnings for it.
324 if user_api_key_auth.team_id != UI_TEAM_ID:
325 verbose_logger.warning("Failed to get allowed agents for team: %s", e)
326 return UnrestrictedAgentAccess()
328 @staticmethod
329 def _get_config_agent_ids_for_access_groups(config_agents: list, access_groups: list[str]) -> set[str]:
330 """
331 Helper to get agent_ids from config-loaded agents that match any of the given access groups.
332 """
333 server_ids: Final[set[str]] = set()
334 for agent in config_agents:
335 agent_access_groups = getattr(agent, "agent_access_groups", None)
336 if agent_access_groups:
337 if any(group in agent_access_groups for group in access_groups):
338 server_ids.add(agent.agent_id)
339 return server_ids
341 @staticmethod
342 async def _get_db_agent_ids_for_access_groups(prisma_client, access_groups: list[str]) -> set[str]:
343 """
344 Helper to get agent_ids from DB agents that match any of the given access groups.
346 Query failures propagate so the caller can tell "this group is empty" (deny)
347 apart from "the lookup failed" (fail-open).
348 """
349 if not access_groups or prisma_client is None:
350 return set()
352 agents: Final = await AgentsRepository(prisma_client).table.find_many(
353 where={"agent_access_groups": {"hasSome": access_groups}}
354 )
355 return {agent.agent_id for agent in agents}
357 @staticmethod
358 async def _get_unified_access_group_agents(access_group_ids: list[str]) -> list[str]:
359 """
360 Resolve unified access group ids to agent IDs.
361 """
362 from litellm.proxy.auth.auth_checks import _get_agent_ids_from_access_groups
364 return await _get_agent_ids_from_access_groups(access_group_ids=access_group_ids)
366 @staticmethod
367 async def _get_agents_from_access_groups(
368 access_groups: list[str],
369 ) -> list[str]:
370 """
371 Resolve agent access groups to agent IDs by querying BOTH the agent table (DB) AND config-loaded agents.
372 """
373 from litellm.proxy.agent_endpoints.agent_registry import global_agent_registry
374 from litellm.proxy.proxy_server import prisma_client
376 # Use the helper for config-loaded agents
377 config_agent_ids: Final = AgentRequestHandler._get_config_agent_ids_for_access_groups(
378 global_agent_registry.agent_list, access_groups
379 )
381 # Use the helper for DB agents
382 db_agent_ids: Final = await AgentRequestHandler._get_db_agent_ids_for_access_groups(
383 prisma_client, access_groups
384 )
386 return list(config_agent_ids | db_agent_ids)
388 @staticmethod
389 async def get_agent_access_groups(
390 user_api_key_auth: UserAPIKeyAuth | None = None,
391 ) -> list[str]:
392 """
393 Get list of agent access groups for the given user/key based on permissions.
394 """
395 access_groups: list[str] = []
396 access_groups_for_key: Final = await AgentRequestHandler._get_agent_access_groups_for_key(user_api_key_auth)
397 access_groups_for_team: Final = await AgentRequestHandler._get_agent_access_groups_for_team(user_api_key_auth)
399 # If team has access groups, then key must have a subset of the team's access groups
400 if len(access_groups_for_team) > 0:
401 for access_group in access_groups_for_key:
402 if access_group in access_groups_for_team:
403 access_groups.append(access_group)
404 else:
405 access_groups = access_groups_for_key
407 return list(set(access_groups))
409 @staticmethod
410 async def _get_agent_access_groups_for_key(
411 user_api_key_auth: UserAPIKeyAuth | None = None,
412 ) -> list[str]:
413 """Get agent access groups for the key."""
414 from litellm.proxy.auth.auth_checks import get_object_permission
415 from litellm.proxy.proxy_server import (
416 prisma_client,
417 proxy_logging_obj,
418 user_api_key_cache,
419 )
421 if user_api_key_auth is None:
422 return []
424 if user_api_key_auth.object_permission_id is None:
425 return []
427 if prisma_client is None:
428 verbose_logger.debug("prisma_client is None")
429 return []
431 try:
432 key_object_permission: Final = await get_object_permission(
433 object_permission_id=user_api_key_auth.object_permission_id,
434 prisma_client=prisma_client,
435 user_api_key_cache=user_api_key_cache,
436 parent_otel_span=user_api_key_auth.parent_otel_span,
437 proxy_logging_obj=proxy_logging_obj,
438 )
439 if key_object_permission is None:
440 return []
442 return key_object_permission.agent_access_groups or []
443 except Exception as e:
444 verbose_logger.warning("Failed to get agent access groups for key: %s", e)
445 return []
447 @staticmethod
448 async def _get_agent_access_groups_for_team(
449 user_api_key_auth: UserAPIKeyAuth | None = None,
450 ) -> list[str]:
451 """Get agent access groups for the team."""
452 from litellm.proxy.auth.auth_checks import get_team_object
453 from litellm.proxy.proxy_server import (
454 prisma_client,
455 proxy_logging_obj,
456 user_api_key_cache,
457 )
459 if user_api_key_auth is None:
460 return []
462 if user_api_key_auth.team_id is None:
463 return []
465 if prisma_client is None:
466 verbose_logger.debug("prisma_client is None")
467 return []
469 try:
470 team_obj: Final[LiteLLM_TeamTable | None] = await get_team_object(
471 team_id=user_api_key_auth.team_id,
472 prisma_client=prisma_client,
473 user_api_key_cache=user_api_key_cache,
474 parent_otel_span=user_api_key_auth.parent_otel_span,
475 proxy_logging_obj=proxy_logging_obj,
476 )
477 if team_obj is None:
478 verbose_logger.debug("team_obj is None")
479 return []
481 object_permissions: Final = team_obj.object_permission
482 if object_permissions is None:
483 return []
485 return object_permissions.agent_access_groups or []
486 except Exception as e:
487 verbose_logger.warning("Failed to get agent access groups for team: %s", e)
488 return []
491def _granted_ids(access: AgentAccess) -> frozenset[str]:
492 match access:
493 case UnrestrictedAgentAccess():
494 return frozenset()
495 case RestrictedAgentAccess(agent_ids):
496 return agent_ids
499ResolveAgentAccess: TypeAlias = Callable[[UserAPIKeyAuth], Awaitable[AgentAccess]]
500EffectiveAuthContexts: TypeAlias = Callable[[UserAPIKeyAuth], Awaitable[Sequence[UserAPIKeyAuth]]]
503async def _granted_agent_ids(
504 user_api_key_auth: UserAPIKeyAuth,
505 resolve_access: ResolveAgentAccess,
506 effective_contexts: EffectiveAuthContexts,
507) -> frozenset[str]:
508 """Union of the explicit grants reachable from the key, its team, or (for a dashboard session)
509 the user's real teams and user row. No grant anywhere yields the empty set, unlike the
510 open-by-default ``resolve_agent_access`` that guards direct access."""
511 accesses: Final = await asyncio.gather(
512 *(resolve_access(auth_context) for auth_context in await effective_contexts(user_api_key_auth))
513 )
514 return frozenset().union(*(_granted_ids(access) for access in accesses))
517async def accessible_agents(
518 user_api_key_auth: UserAPIKeyAuth,
519 all_agents: tuple[AgentResponse, ...] | None = None,
520 resolve_access: ResolveAgentAccess | None = None,
521 effective_contexts: EffectiveAuthContexts = build_effective_auth_contexts,
522) -> tuple[AgentResponse, ...]:
523 """Every registry agent for proxy admins, else only the agents the caller was granted."""
524 from litellm.proxy.agent_endpoints.agent_registry import global_agent_registry
526 agents: Final = global_agent_registry.get_agent_list() if all_agents is None else all_agents
527 if user_api_key_auth.user_role in (LitellmUserRoles.PROXY_ADMIN, LitellmUserRoles.PROXY_ADMIN.value): 527 ↛ 529line 527 didn't jump to line 529 because the condition on line 527 was always true
528 return agents
529 allowed_agent_ids: Final = await _granted_agent_ids(
530 user_api_key_auth,
531 AgentRequestHandler.resolve_agent_access if resolve_access is None else resolve_access,
532 effective_contexts,
533 )
534 return tuple(agent for agent in agents if agent.agent_id in allowed_agent_ids)