diff --git a/litellm/constants.py b/litellm/constants.py index b55d145777..03f80a8cb7 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -1360,6 +1360,9 @@ SPECIAL_LITELLM_AUTH_TOKEN = ["ui-token"] DEFAULT_MANAGEMENT_OBJECT_IN_MEMORY_CACHE_TTL = int( os.getenv("DEFAULT_MANAGEMENT_OBJECT_IN_MEMORY_CACHE_TTL", 60) ) +DEFAULT_ACCESS_GROUP_CACHE_TTL = int( + os.getenv("DEFAULT_ACCESS_GROUP_CACHE_TTL", 600) +) # Sentry Scrubbing Configuration SENTRY_DENYLIST = [ diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 68d0ed2bb7..d549338972 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -2563,6 +2563,21 @@ class LiteLLM_TagTable(LiteLLMPydanticObjectBase): model_config = ConfigDict(protected_namespaces=()) +class LiteLLM_AccessGroupTable(LiteLLMPydanticObjectBase): + access_group_id: str + access_group_name: str + description: Optional[str] = None + access_model_names: List[str] = [] + access_mcp_server_ids: List[str] = [] + access_agent_ids: List[str] = [] + assigned_team_ids: List[str] = [] + assigned_key_ids: List[str] = [] + created_at: Optional[datetime] = None + created_by: Optional[str] = None + updated_at: Optional[datetime] = None + updated_by: Optional[str] = None + + class LiteLLM_SpendLogs(LiteLLMPydanticObjectBase): request_id: str api_key: str diff --git a/litellm/proxy/agent_endpoints/auth/agent_permission_handler.py b/litellm/proxy/agent_endpoints/auth/agent_permission_handler.py index bf3256cf47..9556434250 100644 --- a/litellm/proxy/agent_endpoints/auth/agent_permission_handler.py +++ b/litellm/proxy/agent_endpoints/auth/agent_permission_handler.py @@ -145,7 +145,10 @@ class AgentRequestHandler: user_api_key_auth: Optional[UserAPIKeyAuth] = None, ) -> List[str]: """ - Get allowed agents for a key from its object_permission. + Get allowed agents for a key. + + 1. First checks native key-level agent permissions (object_permission) + 2. Also includes agents from key's access_group_ids (unified access groups) Note: object_permission is already loaded by get_key_object() in main auth flow. """ @@ -153,25 +156,37 @@ class AgentRequestHandler: return [] try: - # Get key object permission (already loaded in main auth flow) + all_agents: List[str] = [] + + # 1. Get agents from object_permission (native permissions) key_object_permission = AgentRequestHandler._get_key_object_permission( user_api_key_auth ) - if key_object_permission is None: - return [] + if key_object_permission is not None: + # Get direct agents + direct_agents = key_object_permission.agents or [] - # Get direct agents - direct_agents = key_object_permission.agents or [] - - # Get agents from access groups - access_group_agents = ( - await AgentRequestHandler._get_agents_from_access_groups( - key_object_permission.agent_access_groups or [] + # Get agents from access groups + access_group_agents = ( + await AgentRequestHandler._get_agents_from_access_groups( + key_object_permission.agent_access_groups or [] + ) ) - ) - # Combine both lists - all_agents = direct_agents + access_group_agents + all_agents = direct_agents + access_group_agents + + # 2. Fallback: get agent IDs from key's access_group_ids (unified access groups) + key_access_group_ids = user_api_key_auth.access_group_ids or [] + if key_access_group_ids: + from litellm.proxy.auth.auth_checks import ( + _get_agent_ids_from_access_groups, + ) + + unified_agents = await _get_agent_ids_from_access_groups( + access_group_ids=key_access_group_ids, + ) + all_agents.extend(unified_agents) + return list(set(all_agents)) except Exception as e: verbose_logger.warning(f"Failed to get allowed agents for key: {str(e)}") @@ -182,9 +197,12 @@ class AgentRequestHandler: user_api_key_auth: Optional[UserAPIKeyAuth] = None, ) -> List[str]: """ - Get allowed agents for a team from its object_permission. + Get allowed agents for a team. - Note: object_permission is already loaded by get_team_object() in main auth flow. + 1. First checks native team-level agent permissions (object_permission) + 2. Also includes agents from team's access_group_ids (unified access groups) + + Fetches the team object once and reuses it for both permission sources. """ if user_api_key_auth is None: return [] @@ -193,26 +211,57 @@ class AgentRequestHandler: return [] try: - # Get team object permission (already loaded in main auth flow) - object_permissions = await AgentRequestHandler._get_team_object_permission( - user_api_key_auth + from litellm.proxy.auth.auth_checks import get_team_object + from litellm.proxy.proxy_server import ( + prisma_client, + proxy_logging_obj, + user_api_key_cache, ) - if object_permissions is None: + if not prisma_client: return [] - # Get direct agents - direct_agents = object_permissions.agents or [] - - # Get agents from access groups - access_group_agents = ( - await AgentRequestHandler._get_agents_from_access_groups( - object_permissions.agent_access_groups or [] - ) + # Fetch the team object once for both permission sources + team_obj = await get_team_object( + team_id=user_api_key_auth.team_id, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + parent_otel_span=user_api_key_auth.parent_otel_span, + proxy_logging_obj=proxy_logging_obj, ) - # Combine both lists - all_agents = direct_agents + access_group_agents + if team_obj is None: + return [] + + all_agents: List[str] = [] + + # 1. Get agents from object_permission (native permissions) + object_permissions = team_obj.object_permission + if object_permissions is not None: + # Get direct agents + direct_agents = object_permissions.agents or [] + + # Get agents from access groups + access_group_agents = ( + await AgentRequestHandler._get_agents_from_access_groups( + object_permissions.agent_access_groups or [] + ) + ) + + all_agents = direct_agents + access_group_agents + + # 2. Also include agents from team's access_group_ids (unified access groups) + team_access_group_ids = team_obj.access_group_ids or [] + if team_access_group_ids: + from litellm.proxy.auth.auth_checks import ( + _get_agent_ids_from_access_groups, + ) + + unified_agents = await _get_agent_ids_from_access_groups( + access_group_ids=team_access_group_ids, + ) + all_agents.extend(unified_agents) + return list(set(all_agents)) except Exception as e: verbose_logger.warning(f"Failed to get allowed agents for team: {str(e)}") diff --git a/litellm/proxy/auth/auth_checks.py b/litellm/proxy/auth/auth_checks.py index 76ec67ab10..3eb6f28ddf 100644 --- a/litellm/proxy/auth/auth_checks.py +++ b/litellm/proxy/auth/auth_checks.py @@ -23,6 +23,7 @@ from litellm.caching.dual_cache import LimitedSizeOrderedDict from litellm.constants import ( CLI_JWT_EXPIRATION_HOURS, CLI_JWT_TOKEN_NAME, + DEFAULT_ACCESS_GROUP_CACHE_TTL, DEFAULT_IN_MEMORY_TTL, DEFAULT_MANAGEMENT_OBJECT_IN_MEMORY_CACHE_TTL, DEFAULT_MAX_RECURSE_DEPTH, @@ -32,6 +33,7 @@ from litellm.litellm_core_utils.get_llm_provider_logic import get_llm_provider from litellm.proxy._types import ( RBAC_ROLES, CallInfo, + LiteLLM_AccessGroupTable, LiteLLM_BudgetTable, LiteLLM_EndUserTable, Litellm_EntityType, @@ -210,7 +212,7 @@ async def common_checks( # 2. If team can call model if _model and team_object: - if not can_team_access_model( + if not await can_team_access_model( model=_model, team_object=team_object, llm_router=llm_router, @@ -1499,6 +1501,110 @@ async def get_team_object( ) +async def _cache_access_object( + access_group_id: str, + access_group_table: LiteLLM_AccessGroupTable, + user_api_key_cache: DualCache, + proxy_logging_obj: Optional[ProxyLogging] = None, +): + key = "access_group_id:{}".format(access_group_id) + await user_api_key_cache.async_set_cache( + key=key, + value=access_group_table, + ttl=DEFAULT_ACCESS_GROUP_CACHE_TTL, + ) + + +async def _delete_cache_access_object( + access_group_id: str, + user_api_key_cache: DualCache, + proxy_logging_obj: Optional[ProxyLogging] = None, +): + key = "access_group_id:{}".format(access_group_id) + + user_api_key_cache.delete_cache(key=key) + + ## UPDATE REDIS CACHE ## + if proxy_logging_obj is not None: + await proxy_logging_obj.internal_usage_cache.dual_cache.async_delete_cache( + key=key + ) + + +@log_db_metrics +async def get_access_object( + access_group_id: str, + prisma_client: Optional[PrismaClient], + user_api_key_cache: DualCache, + proxy_logging_obj: Optional[ProxyLogging] = None, +) -> LiteLLM_AccessGroupTable: + """ + - Check if access_group_id in proxy AccessGroupTable + - Always checks cache first, then DB only when not found in cache + - if valid, return LiteLLM_AccessGroupTable object + - if not, then raise an error + + Unlike get_team_object, this has no check_cache_only or check_db_only flags; + it always follows cache-first-then-db semantics. + + Raises: + - HTTPException: If access group doesn't exist in db or cache (status_code=404) + """ + if prisma_client is None: + raise Exception( + "No DB Connected. See - https://docs.litellm.ai/docs/proxy/virtual_keys" + ) + + key = "access_group_id:{}".format(access_group_id) + + # Always check cache first + cached_access_obj = await user_api_key_cache.async_get_cache(key=key) + if cached_access_obj is not None: + if isinstance(cached_access_obj, dict): + return LiteLLM_AccessGroupTable(**cached_access_obj) + elif isinstance(cached_access_obj, LiteLLM_AccessGroupTable): + return cached_access_obj + + # Not in cache - fetch from DB + try: + response = await prisma_client.db.litellm_accessgrouptable.find_unique( + where={"access_group_id": access_group_id} + ) + + if response is None: + raise HTTPException( + status_code=404, + detail={ + "error": f"Access group doesn't exist in db. Access group={access_group_id}." + }, + ) + + _response = LiteLLM_AccessGroupTable(**response.dict()) + + # Save to cache + await _cache_access_object( + access_group_id=access_group_id, + access_group_table=_response, + user_api_key_cache=user_api_key_cache, + proxy_logging_obj=proxy_logging_obj, + ) + + return _response + except HTTPException: + raise + except Exception as e: + verbose_proxy_logger.exception( + "Error getting access group for access_group_id: %s", + access_group_id, + ) + raise HTTPException( + status_code=404, + detail={ + "error": f"Access group doesn't exist in db. Access group={access_group_id}. Error: {e}" + }, + ) + + @log_db_metrics async def get_team_object_by_alias( team_alias: str, @@ -2013,6 +2119,126 @@ async def get_org_object( ) +async def _get_resources_from_access_groups( + access_group_ids: List[str], + resource_field: Literal[ + "access_model_names", "access_mcp_server_ids", "access_agent_ids" + ], + prisma_client: Optional[PrismaClient] = None, + user_api_key_cache: Optional[DualCache] = None, + proxy_logging_obj: Optional[ProxyLogging] = None, +) -> List[str]: + """ + Fetch access groups by their IDs (from cache or DB) and collect + the specified resource field across all of them. + + Args: + access_group_ids: List of access group IDs to fetch + resource_field: Which resource list to extract from each access group + - "access_model_names": model names (for model access checks) + - "access_mcp_server_ids": MCP server IDs (for MCP access checks) + - "access_agent_ids": agent IDs (for agent access checks) + prisma_client: Optional PrismaClient (lazy-imported from proxy_server if None) + user_api_key_cache: Optional DualCache (lazy-imported from proxy_server if None) + proxy_logging_obj: Optional ProxyLogging (lazy-imported from proxy_server if None) + + Returns: + Deduplicated list of resource identifiers from all resolved access groups. + """ + if not access_group_ids: + return [] + + # Lazy import to avoid circular imports + if prisma_client is None or user_api_key_cache is None: + from litellm.proxy.proxy_server import ( + prisma_client as _prisma_client, + proxy_logging_obj as _proxy_logging_obj, + user_api_key_cache as _user_api_key_cache, + ) + + prisma_client = prisma_client or _prisma_client + user_api_key_cache = user_api_key_cache or _user_api_key_cache + proxy_logging_obj = proxy_logging_obj or _proxy_logging_obj + + if user_api_key_cache is None: + return [] + + resources: List[str] = [] + for ag_id in access_group_ids: + try: + ag = await get_access_object( + access_group_id=ag_id, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + proxy_logging_obj=proxy_logging_obj, + ) + resources.extend(getattr(ag, resource_field, [])) + except Exception: + verbose_proxy_logger.debug( + "Could not fetch access group %s for resource field %s", + ag_id, + resource_field, + ) + return list(set(resources)) + + +async def _get_models_from_access_groups( + access_group_ids: List[str], + prisma_client: Optional[PrismaClient] = None, + user_api_key_cache: Optional[DualCache] = None, + proxy_logging_obj: Optional[ProxyLogging] = None, +) -> List[str]: + """ + Collect model names from unified access groups. + Models are matched by model name for backwards compatibility. + """ + return await _get_resources_from_access_groups( + access_group_ids=access_group_ids, + resource_field="access_model_names", + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + proxy_logging_obj=proxy_logging_obj, + ) + + +async def _get_mcp_server_ids_from_access_groups( + access_group_ids: List[str], + prisma_client: Optional[PrismaClient] = None, + user_api_key_cache: Optional[DualCache] = None, + proxy_logging_obj: Optional[ProxyLogging] = None, +) -> List[str]: + """ + Collect MCP server IDs from unified access groups. + MCPs are matched by server ID. + """ + return await _get_resources_from_access_groups( + access_group_ids=access_group_ids, + resource_field="access_mcp_server_ids", + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + proxy_logging_obj=proxy_logging_obj, + ) + + +async def _get_agent_ids_from_access_groups( + access_group_ids: List[str], + prisma_client: Optional[PrismaClient] = None, + user_api_key_cache: Optional[DualCache] = None, + proxy_logging_obj: Optional[ProxyLogging] = None, +) -> List[str]: + """ + Collect agent IDs from unified access groups. + Agents are matched by agent ID. + """ + return await _get_resources_from_access_groups( + access_group_ids=access_group_ids, + resource_field="access_agent_ids", + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + proxy_logging_obj=proxy_logging_obj, + ) + + def _check_model_access_helper( model: str, llm_router: Optional[Router], @@ -2165,20 +2391,41 @@ async def can_key_call_model( """ Checks if token can call a given model + 1. First checks native key-level model permissions (current implementation) + 2. If not allowed natively, falls back to access_group_ids on the key + Returns: - True: if token allowed to call model Raises: - Exception: If token not allowed to call model """ - return _can_object_call_model( - model=model, - llm_router=llm_router, - models=valid_token.models, - team_model_aliases=valid_token.team_model_aliases, - team_id=valid_token.team_id, - object_type="key", - ) + try: + return _can_object_call_model( + model=model, + llm_router=llm_router, + models=valid_token.models, + team_model_aliases=valid_token.team_model_aliases, + team_id=valid_token.team_id, + object_type="key", + ) + except ProxyException: + # Fallback: check key's access_group_ids + key_access_group_ids = valid_token.access_group_ids or [] + if key_access_group_ids: + models_from_groups = await _get_models_from_access_groups( + access_group_ids=key_access_group_ids, + ) + if models_from_groups: + return _can_object_call_model( + model=model, + llm_router=llm_router, + models=models_from_groups, + team_model_aliases=valid_token.team_model_aliases, + team_id=valid_token.team_id, + object_type="key", + ) + raise def can_org_access_model( @@ -2200,7 +2447,7 @@ def can_org_access_model( ) -def can_team_access_model( +async def can_team_access_model( model: Union[str, List[str]], team_object: Optional[LiteLLM_TeamTable], llm_router: Optional[Router], @@ -2209,15 +2456,37 @@ def can_team_access_model( """ Returns True if the team can access a specific model. + 1. First checks native team-level model permissions (current implementation) + 2. If not allowed natively, falls back to access_group_ids on the team """ - return _can_object_call_model( - model=model, - llm_router=llm_router, - models=team_object.models if team_object else [], - team_model_aliases=team_model_aliases, - team_id=team_object.team_id if team_object else None, - object_type="team", - ) + try: + return _can_object_call_model( + model=model, + llm_router=llm_router, + models=team_object.models if team_object else [], + team_model_aliases=team_model_aliases, + team_id=team_object.team_id if team_object else None, + object_type="team", + ) + except ProxyException: + # Fallback: check team's access_group_ids + team_access_group_ids = ( + (team_object.access_group_ids or []) if team_object else [] + ) + if team_access_group_ids: + models_from_groups = await _get_models_from_access_groups( + access_group_ids=team_access_group_ids, + ) + if models_from_groups: + return _can_object_call_model( + model=model, + llm_router=llm_router, + models=models_from_groups, + team_model_aliases=team_model_aliases, + team_id=team_object.team_id if team_object else None, + object_type="team", + ) + raise async def can_user_call_model( diff --git a/litellm/proxy/auth/handle_jwt.py b/litellm/proxy/auth/handle_jwt.py index f5c3923028..9921b74b56 100644 --- a/litellm/proxy/auth/handle_jwt.py +++ b/litellm/proxy/auth/handle_jwt.py @@ -964,7 +964,7 @@ class JWTAuthManager: team_models = team_object.models if isinstance(team_models, list) and ( not requested_model - or can_team_access_model( + or await can_team_access_model( model=requested_model, team_object=team_object, llm_router=llm_router, diff --git a/litellm/proxy/auth/user_api_key_auth.py b/litellm/proxy/auth/user_api_key_auth.py index f643f7205b..77d96e2d39 100644 --- a/litellm/proxy/auth/user_api_key_auth.py +++ b/litellm/proxy/auth/user_api_key_auth.py @@ -1187,18 +1187,27 @@ async def _user_api_key_auth_builder( # noqa: PLR0915 # Check 6: Additional Common Checks across jwt + key auth if valid_token.team_id is not None: - _team_obj: Optional[LiteLLM_TeamTable] = LiteLLM_TeamTable( - team_id=valid_token.team_id, - max_budget=valid_token.team_max_budget, - soft_budget=valid_token.team_soft_budget, - spend=valid_token.team_spend, - tpm_limit=valid_token.team_tpm_limit, - rpm_limit=valid_token.team_rpm_limit, - blocked=valid_token.team_blocked, - models=valid_token.team_models, - metadata=valid_token.team_metadata, - object_permission_id=valid_token.team_object_permission_id, - ) + try: + _team_obj = await get_team_object( + team_id=valid_token.team_id, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + parent_otel_span=parent_otel_span, + proxy_logging_obj=proxy_logging_obj, + ) + except HTTPException: + _team_obj = LiteLLM_TeamTableCachedObj( + team_id=valid_token.team_id, + max_budget=valid_token.team_max_budget, + soft_budget=valid_token.team_soft_budget, + spend=valid_token.team_spend, + tpm_limit=valid_token.team_tpm_limit, + rpm_limit=valid_token.team_rpm_limit, + blocked=valid_token.team_blocked, + models=valid_token.team_models, + metadata=valid_token.team_metadata, + object_permission_id=valid_token.team_object_permission_id, + ) else: _team_obj = None diff --git a/litellm/proxy/management_endpoints/access_group_endpoints.py b/litellm/proxy/management_endpoints/access_group_endpoints.py index 100b1d2659..12aa748bbc 100644 --- a/litellm/proxy/management_endpoints/access_group_endpoints.py +++ b/litellm/proxy/management_endpoints/access_group_endpoints.py @@ -3,7 +3,19 @@ from typing import List from fastapi import APIRouter, Depends, HTTPException, status from litellm._logging import verbose_proxy_logger -from litellm.proxy._types import CommonProxyErrors, LitellmUserRoles, UserAPIKeyAuth +from litellm.proxy._types import ( + CommonProxyErrors, + LiteLLM_AccessGroupTable, + LitellmUserRoles, + UserAPIKeyAuth, +) +from litellm.proxy.auth.auth_checks import ( + _cache_access_object, + _cache_key_object, + _cache_team_object, + _delete_cache_access_object, + _get_team_object_from_cache, +) from litellm.proxy.auth.user_api_key_auth import user_api_key_auth from litellm.proxy.db.exception_handler import PrismaDBExceptionHandler from litellm.proxy.utils import get_prisma_client_or_throw @@ -43,6 +55,45 @@ def _record_to_response(record) -> AccessGroupResponse: ) +def _record_to_access_group_table(record) -> LiteLLM_AccessGroupTable: + """Convert a Prisma record to a LiteLLM_AccessGroupTable pydantic object for caching.""" + return LiteLLM_AccessGroupTable(**record.dict()) + + +async def _cache_access_group_record(record) -> None: + """ + Cache an access group Prisma record in the user_api_key_cache. + + Uses a lazy import of user_api_key_cache and proxy_logging_obj from proxy_server + to avoid circular imports, following the same pattern as key_management_endpoints. + """ + from litellm.proxy.proxy_server import proxy_logging_obj, user_api_key_cache + + access_group_table = _record_to_access_group_table(record) + await _cache_access_object( + access_group_id=record.access_group_id, + access_group_table=access_group_table, + user_api_key_cache=user_api_key_cache, + proxy_logging_obj=proxy_logging_obj, + ) + + +async def _invalidate_cache_access_group(access_group_id: str) -> None: + """ + Invalidate (delete) an access group entry from both in-memory and Redis caches. + + Uses a lazy import of user_api_key_cache and proxy_logging_obj from proxy_server + to avoid circular imports, following the same pattern as key_management_endpoints. + """ + from litellm.proxy.proxy_server import proxy_logging_obj, user_api_key_cache + + await _delete_cache_access_object( + access_group_id=access_group_id, + user_api_key_cache=user_api_key_cache, + proxy_logging_obj=proxy_logging_obj, + ) + + @router.post( "/v1/access_group", response_model=AccessGroupResponse, @@ -87,6 +138,10 @@ async def create_access_group( detail=f"Access group '{data.access_group_name}' already exists", ) raise + + # Cache the newly created access group for read-heavy access patterns + await _cache_access_group_record(record) + return _record_to_response(record) @@ -166,6 +221,10 @@ async def update_access_group( detail=f"Access group '{update_data.get('access_group_name', '')}' already exists", ) raise + + # Write the updated record into cache (same key, overwrites stale entry) + await _cache_access_group_record(record) + return _record_to_response(record) @@ -181,6 +240,10 @@ async def delete_access_group( prisma_client = get_prisma_client_or_throw(CommonProxyErrors.db_not_connected_error.value) try: + # Track affected team IDs and key tokens for cache invalidation + affected_team_ids: list = [] + affected_key_tokens: list = [] + async with prisma_client.db.tx() as tx: existing = await tx.litellm_accessgrouptable.find_unique( where={"access_group_id": access_group_id} @@ -196,6 +259,7 @@ async def delete_access_group( where={"access_group_ids": {"hasSome": [access_group_id]}} ) for team in teams_with_group: + affected_team_ids.append(team.team_id) updated_ids = [tid for tid in (team.access_group_ids or []) if tid != access_group_id] await tx.litellm_teamtable.update( where={"team_id": team.team_id}, @@ -206,6 +270,7 @@ async def delete_access_group( where={"access_group_ids": {"hasSome": [access_group_id]}} ) for key in keys_with_group: + affected_key_tokens.append(key.token) updated_ids = [kid for kid in (key.access_group_ids or []) if kid != access_group_id] await tx.litellm_verificationtoken.update( where={"token": key.token}, @@ -215,6 +280,48 @@ async def delete_access_group( await tx.litellm_accessgrouptable.delete( where={"access_group_id": access_group_id} ) + + # Invalidate the deleted access group from cache + await _invalidate_cache_access_group(access_group_id) + + # Patch cached team and key objects to remove the deleted access_group_id + # instead of fully invalidating them (keeps cache warm, avoids DB re-fetch) + from litellm.proxy.proxy_server import proxy_logging_obj, user_api_key_cache + + for team_id in affected_team_ids: + cached_team = await _get_team_object_from_cache( + key="team_id:{}".format(team_id), + proxy_logging_obj=proxy_logging_obj, + user_api_key_cache=user_api_key_cache, + parent_otel_span=None, + ) + if cached_team is not None and cached_team.access_group_ids: + cached_team.access_group_ids = [ + ag_id for ag_id in cached_team.access_group_ids if ag_id != access_group_id + ] + await _cache_team_object( + team_id=team_id, + team_table=cached_team, + user_api_key_cache=user_api_key_cache, + proxy_logging_obj=proxy_logging_obj, + ) + + for token in affected_key_tokens: + cached_key = await user_api_key_cache.async_get_cache(key=token) + if cached_key is not None: + if isinstance(cached_key, dict): + cached_key = UserAPIKeyAuth(**cached_key) + if isinstance(cached_key, UserAPIKeyAuth) and cached_key.access_group_ids: + cached_key.access_group_ids = [ + ag_id for ag_id in cached_key.access_group_ids if ag_id != access_group_id + ] + await _cache_key_object( + hashed_token=token, + user_api_key_obj=cached_key, + user_api_key_cache=user_api_key_cache, + proxy_logging_obj=proxy_logging_obj, + ) + except HTTPException: raise except Exception as e: diff --git a/litellm/proxy/management_endpoints/key_management_endpoints.py b/litellm/proxy/management_endpoints/key_management_endpoints.py index de470f6e72..d74b92a25f 100644 --- a/litellm/proxy/management_endpoints/key_management_endpoints.py +++ b/litellm/proxy/management_endpoints/key_management_endpoints.py @@ -1599,7 +1599,7 @@ async def _process_single_key_update( status_code=500, detail={"error": "Team object not found for team change validation"}, ) - validate_key_team_change( + await validate_key_team_change( key=existing_key_row, team=team_obj, change_initiated_by=user_api_key_dict, @@ -1828,7 +1828,7 @@ async def update_key_fn( "error": "Team object not found for team change validation" }, ) - validate_key_team_change( + await validate_key_team_change( key=existing_key_row, team=team_obj, change_initiated_by=user_api_key_dict, @@ -2062,7 +2062,7 @@ async def bulk_update_keys( ) -def validate_key_team_change( +async def validate_key_team_change( key: LiteLLM_VerificationToken, team: LiteLLM_TeamTable, change_initiated_by: UserAPIKeyAuth, @@ -2079,7 +2079,7 @@ def validate_key_team_change( # Check if the team has access to the key's models if len(key.models) > 0: for model in key.models: - can_team_access_model( + await can_team_access_model( model=model, team_object=team, llm_router=llm_router, @@ -2479,6 +2479,7 @@ async def generate_key_helper_fn( # noqa: PLR0915 auto_rotate: Optional[bool] = None, rotation_interval: Optional[str] = None, router_settings: Optional[dict] = None, + access_group_ids: Optional[list] = None, ): from litellm.proxy.proxy_server import premium_user, prisma_client @@ -2595,6 +2596,7 @@ async def generate_key_helper_fn( # noqa: PLR0915 "allowed_routes": allowed_routes or [], "object_permission_id": object_permission_id, "router_settings": router_settings_json, + "access_group_ids": access_group_ids or [], } # Add rotation fields if auto_rotate is enabled diff --git a/tests/proxy_unit_tests/test_auth_checks.py b/tests/proxy_unit_tests/test_auth_checks.py index 66dfc8d15d..5d63742c10 100644 --- a/tests/proxy_unit_tests/test_auth_checks.py +++ b/tests/proxy_unit_tests/test_auth_checks.py @@ -413,7 +413,7 @@ async def test_can_team_access_model(model, team_models, expect_to_work): team_id="test-team", models=team_models, ) - result = can_team_access_model( + result = await can_team_access_model( model=model, team_object=team_object, llm_router=None, @@ -754,3 +754,225 @@ async def test_can_key_call_model_with_aliases(model, alias_map, expect_to_work) valid_token=user_api_key_object, llm_router=router, ) + + +# --------------------------------------------------------------------------- +# Access group cache helpers (_cache_access_object, _delete_cache_access_object) +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_cache_access_object(): + """Test _cache_access_object stores access group in cache with correct key.""" + from litellm.proxy.auth.auth_checks import _cache_access_object + from litellm.proxy._types import LiteLLM_AccessGroupTable + + cache = DualCache() + ag_id = "ag-test-123" + ag_table = LiteLLM_AccessGroupTable( + access_group_id=ag_id, + access_group_name="test-group", + access_model_names=["gpt-4"], + ) + await _cache_access_object( + access_group_id=ag_id, + access_group_table=ag_table, + user_api_key_cache=cache, + ) + cached = await cache.async_get_cache(key=f"access_group_id:{ag_id}") + assert cached is not None + if isinstance(cached, dict): + assert cached.get("access_group_id") == ag_id + assert cached.get("access_group_name") == "test-group" + else: + assert cached.access_group_id == ag_id + assert cached.access_group_name == "test-group" + + +@pytest.mark.asyncio +async def test_delete_cache_access_object(): + """Test _delete_cache_access_object removes access group from in-memory cache.""" + from litellm.proxy.auth.auth_checks import _delete_cache_access_object + from litellm.proxy._types import LiteLLM_AccessGroupTable + + cache = DualCache() + ag_id = "ag-delete-test" + ag_table = LiteLLM_AccessGroupTable( + access_group_id=ag_id, + access_group_name="to-delete", + ) + await cache.async_set_cache(key=f"access_group_id:{ag_id}", value=ag_table, ttl=60) + await _delete_cache_access_object(access_group_id=ag_id, user_api_key_cache=cache) + cached = await cache.async_get_cache(key=f"access_group_id:{ag_id}") + assert cached is None + + +# --------------------------------------------------------------------------- +# Access group resource fetchers (_get_models_from_access_groups, _get_agent_ids_from_access_groups) +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "resource_field, access_group_data, expected", + [ + ( + "access_model_names", + {"access_group_id": "ag-1", "access_model_names": ["gpt-4", "claude-3"]}, + ["gpt-4", "claude-3"], + ), + ( + "access_agent_ids", + {"access_group_id": "ag-2", "access_agent_ids": ["agent-a", "agent-b"]}, + ["agent-a", "agent-b"], + ), + ( + "access_model_names", + {"access_group_id": "ag-3", "access_model_names": []}, + [], + ), + ], +) +@pytest.mark.asyncio +async def test_get_resources_from_access_groups(resource_field, access_group_data, expected): + """Test _get_resources_from_access_groups returns correct resource list from access groups.""" + from unittest.mock import AsyncMock, MagicMock, patch + + from litellm.proxy._types import LiteLLM_AccessGroupTable + from litellm.proxy.auth.auth_checks import ( + _get_agent_ids_from_access_groups, + _get_models_from_access_groups, + ) + + ag_table = LiteLLM_AccessGroupTable( + access_group_id=access_group_data["access_group_id"], + access_group_name="test", + access_model_names=access_group_data.get("access_model_names", []), + access_agent_ids=access_group_data.get("access_agent_ids", []), + ) + + with patch( + "litellm.proxy.auth.auth_checks.get_access_object", + new_callable=AsyncMock, + return_value=ag_table, + ): + if resource_field == "access_model_names": + result = await _get_models_from_access_groups( + access_group_ids=[access_group_data["access_group_id"]], + prisma_client=MagicMock(), + user_api_key_cache=DualCache(), + ) + else: + result = await _get_agent_ids_from_access_groups( + access_group_ids=[access_group_data["access_group_id"]], + prisma_client=MagicMock(), + user_api_key_cache=DualCache(), + ) + assert sorted(result) == sorted(expected) + + +@pytest.mark.asyncio +async def test_get_models_from_access_groups_empty_ids(): + """Test _get_models_from_access_groups returns empty list when access_group_ids is empty.""" + from litellm.proxy.auth.auth_checks import _get_models_from_access_groups + + result = await _get_models_from_access_groups(access_group_ids=[]) + assert result == [] + + +# --------------------------------------------------------------------------- +# can_team_access_model with access_group_ids fallback +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_can_team_access_model_via_access_group_ids(): + """Test can_team_access_model allows access when team has access_group_ids granting model access.""" + from unittest.mock import AsyncMock, patch + + from litellm.proxy.auth.auth_checks import can_team_access_model + + team_object = LiteLLM_TeamTable( + team_id="test-team", + models=[], + access_group_ids=["ag-with-gpt4"], + ) + + with patch( + "litellm.proxy.auth.auth_checks._get_models_from_access_groups", + new_callable=AsyncMock, + return_value=["gpt-4"], + ): + result = await can_team_access_model( + model="gpt-4", + team_object=team_object, + llm_router=None, + team_model_aliases=None, + ) + assert result is True + + +@pytest.mark.asyncio +async def test_can_team_access_model_access_group_ids_denied(): + """Test can_team_access_model denies when neither team models nor access_group_ids grant access.""" + from unittest.mock import AsyncMock, patch + + from litellm.proxy.auth.auth_checks import can_team_access_model + from litellm.proxy._types import ProxyException + + team_object = LiteLLM_TeamTable( + team_id="test-team", + models=["gpt-3.5-turbo"], + access_group_ids=["ag-other"], + ) + + with patch( + "litellm.proxy.auth.auth_checks._get_models_from_access_groups", + new_callable=AsyncMock, + return_value=["claude-3"], + ): + with pytest.raises(ProxyException): + await can_team_access_model( + model="gpt-4", + team_object=team_object, + llm_router=None, + team_model_aliases=None, + ) + + +# --------------------------------------------------------------------------- +# can_key_call_model with access_group_ids fallback +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_can_key_call_model_via_access_group_ids(): + """Test can_key_call_model allows access when key has access_group_ids granting model access.""" + from unittest.mock import AsyncMock, patch + + from litellm.proxy.auth.auth_checks import can_key_call_model + + user_api_key_object = UserAPIKeyAuth( + token="test-token", + models=[], + access_group_ids=["ag-with-gpt4"], + ) + router = litellm.Router( + model_list=[ + { + "model_name": "gpt-4", + "litellm_params": {"model": "openai/gpt-4", "api_key": "test"}, + } + ] + ) + + with patch( + "litellm.proxy.auth.auth_checks._get_models_from_access_groups", + new_callable=AsyncMock, + return_value=["gpt-4"], + ): + await can_key_call_model( + model="gpt-4", + llm_model_list=[], + valid_token=user_api_key_object, + llm_router=router, + ) diff --git a/tests/test_litellm/proxy/agent_endpoints/auth/test_agent_permission_handler.py b/tests/test_litellm/proxy/agent_endpoints/auth/test_agent_permission_handler.py index 111dd7c076..533dc0557b 100644 --- a/tests/test_litellm/proxy/agent_endpoints/auth/test_agent_permission_handler.py +++ b/tests/test_litellm/proxy/agent_endpoints/auth/test_agent_permission_handler.py @@ -4,7 +4,7 @@ Unit tests for AgentRequestHandler - Agent permission management for keys and te import os import sys -from unittest.mock import patch +from unittest.mock import AsyncMock, patch import pytest @@ -111,3 +111,57 @@ class TestAgentRequestHandler: result = await AgentRequestHandler.get_allowed_agents(user_api_key_auth=mock_user_auth) assert result == [] + + async def test_get_allowed_agents_for_key_via_access_group_ids(self): + """ + Test that _get_allowed_agents_for_key includes agents from key's access_group_ids + (unified access groups) when key has no native object_permission. + """ + mock_user_auth = UserAPIKeyAuth( + api_key="test-key", + user_id="test-user", + access_group_ids=["ag-with-agents"], + ) + + with patch.object( + AgentRequestHandler, "_get_key_object_permission", return_value=None + ): + with patch( + "litellm.proxy.auth.auth_checks._get_agent_ids_from_access_groups", + new_callable=AsyncMock, + return_value=["agent-from-ag-1", "agent-from-ag-2"], + ): + result = await AgentRequestHandler._get_allowed_agents_for_key( + user_api_key_auth=mock_user_auth + ) + assert sorted(result) == ["agent-from-ag-1", "agent-from-ag-2"] + + async def test_get_allowed_agents_for_key_combines_native_and_access_groups(self): + """ + Test that _get_allowed_agents_for_key combines agents from native object_permission + and key's access_group_ids (unified access groups). + """ + from litellm.proxy._types import LiteLLM_ObjectPermissionTable + + mock_permission = LiteLLM_ObjectPermissionTable( + object_permission_id="obj-1", + agents=["native-agent-1"], + agent_access_groups=[], + ) + mock_user_auth = UserAPIKeyAuth( + api_key="test-key", + user_id="test-user", + access_group_ids=["ag-1"], + ) + # Attach object_permission so _get_key_object_permission returns it + mock_user_auth.object_permission = mock_permission + + with patch( + "litellm.proxy.auth.auth_checks._get_agent_ids_from_access_groups", + new_callable=AsyncMock, + return_value=["agent-from-ag"], + ): + result = await AgentRequestHandler._get_allowed_agents_for_key( + user_api_key_auth=mock_user_auth + ) + assert sorted(result) == ["agent-from-ag", "native-agent-1"] diff --git a/tests/test_litellm/proxy/management_endpoints/test_access_group_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_access_group_endpoints.py index 54df8941fa..9b6e063176 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_access_group_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_access_group_endpoints.py @@ -37,19 +37,27 @@ def _make_access_group_record( updated_by: str | None = "admin-user", created_at: datetime | None = None, ): + created_at_val = created_at or datetime.now() + updated_at_val = datetime.now() + data = { + "access_group_id": access_group_id, + "access_group_name": access_group_name, + "description": description, + "access_model_names": access_model_names or [], + "access_mcp_server_ids": access_mcp_server_ids or [], + "access_agent_ids": access_agent_ids or [], + "assigned_team_ids": assigned_team_ids or [], + "assigned_key_ids": assigned_key_ids or [], + "created_at": created_at_val, + "created_by": created_by, + "updated_at": updated_at_val, + "updated_by": updated_by, + } record = MagicMock() - record.access_group_id = access_group_id - record.access_group_name = access_group_name - record.description = description - record.access_model_names = access_model_names or [] - record.access_mcp_server_ids = access_mcp_server_ids or [] - record.access_agent_ids = access_agent_ids or [] - record.assigned_team_ids = assigned_team_ids or [] - record.assigned_key_ids = assigned_key_ids or [] - record.created_at = created_at or datetime.now() - record.created_by = created_by - record.updated_at = datetime.now() - record.updated_by = updated_by + for k, v in data.items(): + setattr(record, k, v) + record.dict = lambda: data + record.model_dump = lambda: data return record @@ -116,6 +124,27 @@ def client_and_mocks(monkeypatch): monkeypatch.setattr(ps, "prisma_client", mock_prisma) + # Mock user_api_key_cache and proxy_logging_obj for cache operations (create/update/delete) + mock_cache = MagicMock() + mock_cache.async_set_cache = AsyncMock(return_value=None) + mock_cache.async_get_cache = AsyncMock(return_value=None) + mock_cache.delete_cache = MagicMock(return_value=None) + monkeypatch.setattr(ps, "user_api_key_cache", mock_cache) + + mock_proxy_logging = MagicMock() + mock_proxy_logging.internal_usage_cache = MagicMock() + mock_proxy_logging.internal_usage_cache.dual_cache = MagicMock() + mock_proxy_logging.internal_usage_cache.dual_cache.async_delete_cache = AsyncMock( + return_value=None + ) + mock_proxy_logging.internal_usage_cache.dual_cache.async_get_cache = AsyncMock( + return_value=None + ) + mock_proxy_logging.internal_usage_cache.dual_cache.async_set_cache = AsyncMock( + return_value=None + ) + monkeypatch.setattr(ps, "proxy_logging_obj", mock_proxy_logging) + admin_user = UserAPIKeyAuth( user_id="admin_user", user_role=LitellmUserRoles.PROXY_ADMIN, @@ -124,7 +153,7 @@ def client_and_mocks(monkeypatch): client = TestClient(app) - yield client, mock_prisma, mock_access_group_table + yield client, mock_prisma, mock_access_group_table, mock_cache, mock_proxy_logging app.dependency_overrides.clear() monkeypatch.setattr(ps, "prisma_client", ps.prisma_client) @@ -155,7 +184,7 @@ ACCESS_GROUP_PATHS = ["/v1/access_group", "/v1/unified_access_group"] ) def test_create_access_group_success(client_and_mocks, base_path, payload): """Create access group with various payloads returns 201.""" - client, _, mock_table = client_and_mocks + client, _, mock_table, *_ = client_and_mocks resp = client.post(base_path, json=payload) assert resp.status_code == 201 @@ -167,7 +196,7 @@ def test_create_access_group_success(client_and_mocks, base_path, payload): def test_create_access_group_duplicate_name_conflict(client_and_mocks): """Create with duplicate name returns 409.""" - client, _, mock_table = client_and_mocks + client, _, mock_table, *_ = client_and_mocks existing = _make_access_group_record(access_group_name="existing-group") mock_table.find_unique = AsyncMock(return_value=existing) @@ -187,7 +216,7 @@ def test_create_access_group_duplicate_name_conflict(client_and_mocks): ) def test_create_access_group_race_condition_returns_409(client_and_mocks, error_message): """Create race condition: Prisma unique constraint surfaces as 409, not 500.""" - client, _, mock_table = client_and_mocks + client, _, mock_table, *_ = client_and_mocks mock_table.find_unique = AsyncMock(return_value=None) mock_table.create = AsyncMock(side_effect=Exception(error_message)) @@ -200,7 +229,7 @@ def test_create_access_group_race_condition_returns_409(client_and_mocks, error_ @pytest.mark.parametrize("user_role", [LitellmUserRoles.INTERNAL_USER, LitellmUserRoles.INTERNAL_USER_VIEW_ONLY]) def test_create_access_group_forbidden_non_admin(client_and_mocks, user_role): """Non-admin users cannot create access groups.""" - client, _, _ = client_and_mocks + client, *_ = client_and_mocks app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth( user_id="regular_user", @@ -214,7 +243,7 @@ def test_create_access_group_forbidden_non_admin(client_and_mocks, user_role): def test_create_access_group_validation_missing_name(client_and_mocks): """Create with missing access_group_name returns 422.""" - client, _, _ = client_and_mocks + client, *_ = client_and_mocks resp = client.post("/v1/access_group", json={}) assert resp.status_code == 422 @@ -222,7 +251,7 @@ def test_create_access_group_validation_missing_name(client_and_mocks): def test_create_access_group_500_on_non_constraint_prisma_error(client_and_mocks): """Create with non-unique-constraint Prisma error returns 500.""" - client, _, mock_table = client_and_mocks + client, _, mock_table, *_ = client_and_mocks mock_table.find_unique = AsyncMock(return_value=None) mock_table.create = AsyncMock(side_effect=Exception("Some other database error")) @@ -241,7 +270,7 @@ def test_create_access_group_500_on_non_constraint_prisma_error(client_and_mocks @pytest.mark.parametrize("base_path", ACCESS_GROUP_PATHS) def test_list_access_groups_success_empty(client_and_mocks, base_path): """List access groups returns empty list when none exist.""" - client, _, mock_table = client_and_mocks + client, _, mock_table, *_ = client_and_mocks resp = client.get(base_path) assert resp.status_code == 200 @@ -252,7 +281,7 @@ def test_list_access_groups_success_empty(client_and_mocks, base_path): @pytest.mark.parametrize("base_path", ACCESS_GROUP_PATHS) def test_list_access_groups_success_with_items(client_and_mocks, base_path): """List access groups returns items when they exist.""" - client, _, mock_table = client_and_mocks + client, _, mock_table, *_ = client_and_mocks records = [ _make_access_group_record(access_group_id="ag-1", access_group_name="group-1"), @@ -271,7 +300,7 @@ def test_list_access_groups_success_with_items(client_and_mocks, base_path): @pytest.mark.parametrize("base_path", ACCESS_GROUP_PATHS) def test_list_access_groups_ordered_by_created_at_desc(client_and_mocks, base_path): """List access groups calls find_many with created_at desc order.""" - client, _, mock_table = client_and_mocks + client, _, mock_table, *_ = client_and_mocks older = datetime(2025, 1, 1, 12, 0, 0) newer = datetime(2025, 1, 2, 12, 0, 0) @@ -302,7 +331,7 @@ def test_list_access_groups_ordered_by_created_at_desc(client_and_mocks, base_pa @pytest.mark.parametrize("user_role", [LitellmUserRoles.INTERNAL_USER, LitellmUserRoles.INTERNAL_USER_VIEW_ONLY]) def test_list_access_groups_forbidden_non_admin(client_and_mocks, user_role): """Non-admin users cannot list access groups.""" - client, _, _ = client_and_mocks + client, *_ = client_and_mocks app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth( user_id="regular_user", @@ -323,7 +352,7 @@ def test_list_access_groups_forbidden_non_admin(client_and_mocks, user_role): @pytest.mark.parametrize("access_group_id", ["ag-123", "ag-other-id"]) def test_get_access_group_success(client_and_mocks, base_path, access_group_id): """Get access group by id returns record when found.""" - client, _, mock_table = client_and_mocks + client, _, mock_table, *_ = client_and_mocks record = _make_access_group_record(access_group_id=access_group_id) mock_table.find_unique = AsyncMock(return_value=record) @@ -335,7 +364,7 @@ def test_get_access_group_success(client_and_mocks, base_path, access_group_id): def test_get_access_group_not_found(client_and_mocks): """Get access group returns 404 when not found.""" - client, _, mock_table = client_and_mocks + client, _, mock_table, *_ = client_and_mocks mock_table.find_unique = AsyncMock(return_value=None) @@ -347,7 +376,7 @@ def test_get_access_group_not_found(client_and_mocks): @pytest.mark.parametrize("user_role", [LitellmUserRoles.INTERNAL_USER, LitellmUserRoles.INTERNAL_USER_VIEW_ONLY]) def test_get_access_group_forbidden_non_admin(client_and_mocks, user_role): """Non-admin users cannot get access group.""" - client, _, _ = client_and_mocks + client, *_ = client_and_mocks app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth( user_id="regular_user", @@ -375,7 +404,7 @@ def test_get_access_group_forbidden_non_admin(client_and_mocks, user_role): ) def test_update_access_group_success(client_and_mocks, base_path, update_payload): """Update access group with various payloads returns 200.""" - client, _, mock_table = client_and_mocks + client, _, mock_table, *_ = client_and_mocks existing = _make_access_group_record(access_group_id="ag-update") mock_table.find_unique = AsyncMock(return_value=existing) @@ -387,7 +416,7 @@ def test_update_access_group_success(client_and_mocks, base_path, update_payload def test_update_access_group_not_found(client_and_mocks): """Update access group returns 404 when not found.""" - client, _, mock_table = client_and_mocks + client, _, mock_table, *_ = client_and_mocks mock_table.find_unique = AsyncMock(return_value=None) @@ -403,7 +432,7 @@ def test_update_access_group_not_found(client_and_mocks): @pytest.mark.parametrize("user_role", [LitellmUserRoles.INTERNAL_USER, LitellmUserRoles.INTERNAL_USER_VIEW_ONLY]) def test_update_access_group_forbidden_non_admin(client_and_mocks, user_role): """Non-admin users cannot update access groups.""" - client, _, _ = client_and_mocks + client, *_ = client_and_mocks app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth( user_id="regular_user", @@ -417,7 +446,7 @@ def test_update_access_group_forbidden_non_admin(client_and_mocks, user_role): def test_update_access_group_empty_body(client_and_mocks): """Update with empty body succeeds; only updated_by is set.""" - client, _, mock_table = client_and_mocks + client, _, mock_table, *_ = client_and_mocks existing = _make_access_group_record(access_group_id="ag-update", access_group_name="unchanged") mock_table.find_unique = AsyncMock(return_value=existing) @@ -433,7 +462,7 @@ def test_update_access_group_empty_body(client_and_mocks): def test_update_access_group_name_success(client_and_mocks): """Update access_group_name succeeds when new name is unique.""" - client, _, mock_table = client_and_mocks + client, _, mock_table, *_ = client_and_mocks existing = _make_access_group_record(access_group_id="ag-update", access_group_name="old-name") mock_table.find_unique = AsyncMock(return_value=existing) @@ -447,7 +476,7 @@ def test_update_access_group_name_success(client_and_mocks): def test_update_access_group_name_duplicate_conflict(client_and_mocks): """Update access_group_name to existing name returns 409 (unique constraint).""" - client, _, mock_table = client_and_mocks + client, _, mock_table, *_ = client_and_mocks existing = _make_access_group_record(access_group_id="ag-update", access_group_name="old-name") mock_table.find_unique = AsyncMock(return_value=existing) @@ -471,7 +500,7 @@ def test_update_access_group_name_duplicate_conflict(client_and_mocks): ) def test_update_access_group_name_unique_constraint_returns_409(client_and_mocks, error_message): """Update access_group_name: Prisma unique constraint surfaces as 409.""" - client, _, mock_table = client_and_mocks + client, _, mock_table, *_ = client_and_mocks existing = _make_access_group_record(access_group_id="ag-update", access_group_name="old-name") mock_table.find_unique = AsyncMock(return_value=existing) @@ -491,7 +520,7 @@ def test_update_access_group_name_unique_constraint_returns_409(client_and_mocks @pytest.mark.parametrize("access_group_id", ["ag-123", "ag-delete-me"]) def test_delete_access_group_success(client_and_mocks, base_path, access_group_id): """Delete access group returns 204 when found.""" - client, _, mock_table = client_and_mocks + client, _, mock_table, *_ = client_and_mocks existing = _make_access_group_record(access_group_id=access_group_id) mock_table.find_unique = AsyncMock(return_value=existing) @@ -503,7 +532,7 @@ def test_delete_access_group_success(client_and_mocks, base_path, access_group_i def test_delete_access_group_not_found(client_and_mocks): """Delete access group returns 404 when not found.""" - client, _, mock_table = client_and_mocks + client, _, mock_table, *_ = client_and_mocks mock_table.find_unique = AsyncMock(return_value=None) @@ -516,7 +545,7 @@ def test_delete_access_group_not_found(client_and_mocks): @pytest.mark.parametrize("user_role", [LitellmUserRoles.INTERNAL_USER, LitellmUserRoles.INTERNAL_USER_VIEW_ONLY]) def test_delete_access_group_forbidden_non_admin(client_and_mocks, user_role): """Non-admin users cannot delete access groups.""" - client, _, _ = client_and_mocks + client, *_ = client_and_mocks app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth( user_id="regular_user", @@ -530,7 +559,7 @@ def test_delete_access_group_forbidden_non_admin(client_and_mocks, user_role): def test_delete_access_group_cleans_up_teams_and_keys(client_and_mocks): """Delete removes access_group_id from teams and keys before deleting the group.""" - client, mock_prisma, mock_access_group_table = client_and_mocks + client, mock_prisma, mock_access_group_table, mock_cache, mock_proxy_logging = client_and_mocks mock_team_table = mock_prisma.db.litellm_teamtable mock_key_table = mock_prisma.db.litellm_verificationtoken @@ -563,9 +592,208 @@ def test_delete_access_group_cleans_up_teams_and_keys(client_and_mocks): ) +@pytest.mark.parametrize( + "team_cache_group_ids,key_cache_group_ids,expected_team_ids_after,expected_key_ids_after", + [ + # Team and key both cached with the deleted group + ( + ["ag-to-delete", "ag-keep"], + ["ag-to-delete", "ag-stay"], + ["ag-keep"], + ["ag-stay"], + ), + # Only team cached; key not in cache + ( + ["ag-to-delete"], + None, + [], + None, + ), + # Only key cached; team not in cache + ( + None, + ["ag-to-delete"], + None, + [], + ), + # Neither cached — nothing to patch + ( + None, + None, + None, + None, + ), + # Cached team has only the deleted group + ( + ["ag-to-delete"], + ["ag-to-delete"], + [], + [], + ), + # Cached objects have multiple groups, only the deleted one is removed + ( + ["ag-alpha", "ag-to-delete", "ag-beta"], + ["ag-to-delete", "ag-gamma"], + ["ag-alpha", "ag-beta"], + ["ag-gamma"], + ), + ], + ids=[ + "both_cached", + "only_team_cached", + "only_key_cached", + "neither_cached", + "single_group_removed", + "multi_group_partial_removal", + ], +) +def test_delete_access_group_patches_cached_team_and_key( + client_and_mocks, + team_cache_group_ids, + key_cache_group_ids, + expected_team_ids_after, + expected_key_ids_after, +): + """Delete patches cached team/key objects to remove the deleted access_group_id.""" + from litellm.proxy._types import LiteLLM_TeamTableCachedObj + + client, mock_prisma, mock_access_group_table, mock_cache, mock_proxy_logging = client_and_mocks + mock_team_table = mock_prisma.db.litellm_teamtable + mock_key_table = mock_prisma.db.litellm_verificationtoken + + existing = _make_access_group_record(access_group_id="ag-to-delete") + mock_access_group_table.find_unique = AsyncMock(return_value=existing) + + # Set up a team and key in the DB that reference the group + team_with_group = MagicMock() + team_with_group.team_id = "team-1" + team_with_group.access_group_ids = ["ag-to-delete", "ag-keep"] + mock_team_table.find_many = AsyncMock(return_value=[team_with_group]) + + key_with_group = MagicMock() + key_with_group.token = "hashed-key-1" + key_with_group.access_group_ids = ["ag-to-delete"] + mock_key_table.find_many = AsyncMock(return_value=[key_with_group]) + + # Build cached team object (returned from proxy_logging dual cache) + if team_cache_group_ids is not None: + cached_team = LiteLLM_TeamTableCachedObj( + team_id="team-1", + access_group_ids=list(team_cache_group_ids), + ) + mock_proxy_logging.internal_usage_cache.dual_cache.async_get_cache = AsyncMock( + return_value=cached_team + ) + else: + mock_proxy_logging.internal_usage_cache.dual_cache.async_get_cache = AsyncMock( + return_value=None + ) + + # Build cached key object (returned from user_api_key_cache) + if key_cache_group_ids is not None: + cached_key = UserAPIKeyAuth( + token="hashed-key-1", + access_group_ids=list(key_cache_group_ids), + ) + mock_cache.async_get_cache = AsyncMock(return_value=cached_key) + else: + mock_cache.async_get_cache = AsyncMock(return_value=None) + + resp = client.delete("/v1/access_group/ag-to-delete") + assert resp.status_code == 204 + + # Verify DB cleanup always happens + mock_team_table.update.assert_awaited_once() + mock_key_table.update.assert_awaited_once() + + # Verify cache patching + if expected_team_ids_after is not None: + # _cache_team_object writes via _cache_management_object -> async_set_cache + team_set_calls = [ + c for c in mock_cache.async_set_cache.call_args_list + if c.kwargs.get("key", "") == "team_id:team-1" + or (len(c.args) >= 1 and c.args[0] == "team_id:team-1") + ] + assert len(team_set_calls) >= 1, "Expected team cache to be patched" + # The cached team object should have the updated access_group_ids + written_team = team_set_calls[0].kwargs.get("value") or team_set_calls[0].args[1] + if isinstance(written_team, LiteLLM_TeamTableCachedObj): + assert written_team.access_group_ids == expected_team_ids_after + else: + # No team in cache — async_set_cache should not be called for team_id key + team_set_calls = [ + c for c in mock_cache.async_set_cache.call_args_list + if c.kwargs.get("key", "") == "team_id:team-1" + or (len(c.args) >= 1 and c.args[0] == "team_id:team-1") + ] + assert len(team_set_calls) == 0, "Should not patch team cache when not cached" + + if expected_key_ids_after is not None: + key_set_calls = [ + c for c in mock_cache.async_set_cache.call_args_list + if c.kwargs.get("key", "") == "hashed-key-1" + or (len(c.args) >= 1 and c.args[0] == "hashed-key-1") + ] + assert len(key_set_calls) >= 1, "Expected key cache to be patched" + written_key = key_set_calls[0].kwargs.get("value") or key_set_calls[0].args[1] + if isinstance(written_key, UserAPIKeyAuth): + assert written_key.access_group_ids == expected_key_ids_after + else: + key_set_calls = [ + c for c in mock_cache.async_set_cache.call_args_list + if c.kwargs.get("key", "") == "hashed-key-1" + or (len(c.args) >= 1 and c.args[0] == "hashed-key-1") + ] + assert len(key_set_calls) == 0, "Should not patch key cache when not cached" + + +def test_delete_access_group_patches_key_cached_as_dict(client_and_mocks): + """Delete correctly patches a key cached as a raw dict (not UserAPIKeyAuth).""" + client, mock_prisma, mock_access_group_table, mock_cache, mock_proxy_logging = client_and_mocks + mock_team_table = mock_prisma.db.litellm_teamtable + mock_key_table = mock_prisma.db.litellm_verificationtoken + + existing = _make_access_group_record(access_group_id="ag-to-delete") + mock_access_group_table.find_unique = AsyncMock(return_value=existing) + + mock_team_table.find_many = AsyncMock(return_value=[]) + + key_with_group = MagicMock() + key_with_group.token = "hashed-key-dict" + key_with_group.access_group_ids = ["ag-to-delete", "ag-other"] + mock_key_table.find_many = AsyncMock(return_value=[key_with_group]) + + # No team in cache + mock_proxy_logging.internal_usage_cache.dual_cache.async_get_cache = AsyncMock( + return_value=None + ) + + # Key cached as a plain dict (as can happen with Redis serialization) + mock_cache.async_get_cache = AsyncMock( + return_value={ + "token": "hashed-key-dict", + "access_group_ids": ["ag-to-delete", "ag-other"], + } + ) + + resp = client.delete("/v1/access_group/ag-to-delete") + assert resp.status_code == 204 + + # The key should have been re-cached with the deleted group removed + key_set_calls = [ + c for c in mock_cache.async_set_cache.call_args_list + if c.kwargs.get("key", "") == "hashed-key-dict" + or (len(c.args) >= 1 and c.args[0] == "hashed-key-dict") + ] + assert len(key_set_calls) >= 1, "Expected key cache to be patched" + written_key = key_set_calls[0].kwargs.get("value") or key_set_calls[0].args[1] + if isinstance(written_key, UserAPIKeyAuth): + assert written_key.access_group_ids == ["ag-other"] + + def test_delete_access_group_503_on_db_connection_error(client_and_mocks): """Delete returns 503 when DB connection error occurs during transaction.""" - client, _, mock_table = client_and_mocks + client, _, mock_table, *_ = client_and_mocks existing = _make_access_group_record(access_group_id="ag-to-delete") mock_table.find_unique = AsyncMock(return_value=existing) @@ -578,7 +806,7 @@ def test_delete_access_group_503_on_db_connection_error(client_and_mocks): def test_delete_access_group_404_on_p2025_or_record_not_found(client_and_mocks): """Delete returns 404 when Prisma raises P2025 or record-not-found error.""" - client, _, mock_table = client_and_mocks + client, _, mock_table, *_ = client_and_mocks existing = _make_access_group_record(access_group_id="ag-to-delete") mock_table.find_unique = AsyncMock(return_value=existing) @@ -591,7 +819,7 @@ def test_delete_access_group_404_on_p2025_or_record_not_found(client_and_mocks): def test_delete_access_group_500_on_generic_exception(client_and_mocks): """Delete returns 500 when generic exception occurs during transaction.""" - client, _, mock_table = client_and_mocks + client, _, mock_table, *_ = client_and_mocks existing = _make_access_group_record(access_group_id="ag-to-delete") mock_table.find_unique = AsyncMock(return_value=existing) @@ -625,10 +853,32 @@ def test_delete_access_group_500_on_generic_exception(client_and_mocks): ) def test_access_group_endpoints_db_not_connected(client_and_mocks, monkeypatch, method, url, factory): """All endpoints return 500 when DB is not connected.""" - client, _, _ = client_and_mocks + client, *_ = client_and_mocks monkeypatch.setattr(ps, "prisma_client", None) resp = getattr(client, method)(url, **factory()) assert resp.status_code == 500 assert resp.json()["detail"]["error"] == CommonProxyErrors.db_not_connected_error.value + + +# --------------------------------------------------------------------------- +# Unit tests for cache helpers (_record_to_access_group_table) +# --------------------------------------------------------------------------- + + +def test_record_to_access_group_table(): + """Test _record_to_access_group_table converts Prisma-like record to LiteLLM_AccessGroupTable.""" + from litellm.proxy.management_endpoints.access_group_endpoints import _record_to_access_group_table + + record = _make_access_group_record( + access_group_id="ag-unit-test", + access_group_name="unit-test-group", + access_model_names=["gpt-4", "claude-3"], + access_agent_ids=["agent-1"], + ) + result = _record_to_access_group_table(record) + assert result.access_group_id == "ag-unit-test" + assert result.access_group_name == "unit-test-group" + assert result.access_model_names == ["gpt-4", "claude-3"] + assert result.access_agent_ids == ["agent-1"] diff --git a/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py index 39f8d1cccb..de2c940943 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py @@ -520,6 +520,51 @@ async def test_key_generation_with_object_permission(monkeypatch): assert key_insert_calls[0]["data"].get("object_permission_id") == "objperm123" +@pytest.mark.asyncio +async def test_generate_key_helper_fn_with_access_group_ids(monkeypatch): + """Ensure generate_key_helper_fn passes access_group_ids into the key insert payload.""" + mock_prisma_client = AsyncMock() + mock_prisma_client.jsonify_object = lambda data: data # type: ignore + mock_prisma_client.db = MagicMock() + mock_prisma_client.db.litellm_objectpermissiontable = MagicMock() + mock_prisma_client.db.litellm_objectpermissiontable.create = AsyncMock( + return_value=MagicMock(object_permission_id=None) + ) + + captured_key_data = {} + + async def _insert_data_side_effect(*args, **kwargs): + table_name = kwargs.get("table_name") + if table_name == "user": + return MagicMock(models=[], spend=0) + elif table_name == "key": + captured_key_data.update(kwargs.get("data", {})) + return MagicMock( + token="hashed_token_789", + litellm_budget_table=None, + object_permission=None, + created_at=None, + updated_at=None, + ) + return MagicMock() + + mock_prisma_client.insert_data = AsyncMock(side_effect=_insert_data_side_effect) + monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client) + + from litellm.proxy.management_endpoints.key_management_endpoints import ( + generate_key_helper_fn, + ) + + await generate_key_helper_fn( + request_type="key", + table_name="key", + user_id="test-user", + access_group_ids=["ag-1", "ag-2"], + ) + + assert captured_key_data.get("access_group_ids") == ["ag-1", "ag-2"] + + @pytest.mark.asyncio async def test_key_generation_with_mcp_tool_permissions(monkeypatch): """ @@ -1356,14 +1401,15 @@ async def test_unblock_key_invalid_key_format(monkeypatch): assert "Invalid key format" in str(exc_info.value.message) -def test_validate_key_team_change_with_member_permissions(): +@pytest.mark.asyncio +async def test_validate_key_team_change_with_member_permissions(): """ Test validate_key_team_change function with team member permissions. This test covers the new logic that allows team members with specific permissions to update keys, not just team admins. """ - from unittest.mock import MagicMock, patch + from unittest.mock import AsyncMock, MagicMock, patch from litellm.proxy._types import KeyManagementRoutes @@ -1389,7 +1435,8 @@ def test_validate_key_team_change_with_member_permissions(): mock_member_object = MagicMock() with patch( - "litellm.proxy.management_endpoints.key_management_endpoints.can_team_access_model" + "litellm.proxy.management_endpoints.key_management_endpoints.can_team_access_model", + new_callable=AsyncMock, ): with patch( "litellm.proxy.management_endpoints.key_management_endpoints._get_user_in_team" @@ -1406,7 +1453,7 @@ def test_validate_key_team_change_with_member_permissions(): mock_has_perms.return_value = True # This should not raise an exception due to member permissions - validate_key_team_change( + await validate_key_team_change( key=mock_key, team=mock_team, change_initiated_by=mock_change_initiator, diff --git a/ui/litellm-dashboard/package.json b/ui/litellm-dashboard/package.json index 164368eb6b..42b90d2733 100644 --- a/ui/litellm-dashboard/package.json +++ b/ui/litellm-dashboard/package.json @@ -3,7 +3,7 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "next dev", + "dev": "next dev --webpack", "build": "next build", "start": "next start", "lint": "next lint", diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/accessGroups/useAccessGroups.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/accessGroups/useAccessGroups.test.ts index 587064353a..b15ea4491e 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/accessGroups/useAccessGroups.test.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/accessGroups/useAccessGroups.test.ts @@ -41,7 +41,7 @@ const mockAccessGroups: AccessGroupResponse[] = [ access_group_id: "ag-1", access_group_name: "Group One", description: "First group", - access_model_ids: [], + access_model_names: [], access_mcp_server_ids: [], access_agent_ids: [], assigned_team_ids: [], diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/accessGroups/useAccessGroups.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/accessGroups/useAccessGroups.ts index e5d8829278..215b555fcf 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/accessGroups/useAccessGroups.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/accessGroups/useAccessGroups.ts @@ -15,7 +15,7 @@ export interface AccessGroupResponse { access_group_id: string; access_group_name: string; description: string | null; - access_model_ids: string[]; + access_model_names: string[]; access_mcp_server_ids: string[]; access_agent_ids: string[]; assigned_team_ids: string[]; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/accessGroups/useCreateAccessGroup.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/accessGroups/useCreateAccessGroup.ts index 4d71be9445..7ea5a81346 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/accessGroups/useCreateAccessGroup.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/accessGroups/useCreateAccessGroup.ts @@ -13,7 +13,7 @@ import { AccessGroupResponse, accessGroupKeys } from "./useAccessGroups"; export interface AccessGroupCreateParams { access_group_name: string; description?: string | null; - access_model_ids?: string[]; + access_model_names?: string[]; access_mcp_server_ids?: string[]; access_agent_ids?: string[]; assigned_team_ids?: string[]; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/accessGroups/useEditAccessGroup.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/accessGroups/useEditAccessGroup.ts index 1646458c63..5dc2252f64 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/accessGroups/useEditAccessGroup.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/accessGroups/useEditAccessGroup.ts @@ -13,7 +13,7 @@ import { AccessGroupResponse, accessGroupKeys } from "./useAccessGroups"; export interface AccessGroupUpdateParams { access_group_name?: string; description?: string | null; - access_model_ids?: string[]; + access_model_names?: string[]; access_mcp_server_ids?: string[]; access_agent_ids?: string[]; assigned_team_ids?: string[]; diff --git a/ui/litellm-dashboard/src/components/AccessGroups/AccessGroupsDetailsPage.test.tsx b/ui/litellm-dashboard/src/components/AccessGroups/AccessGroupsDetailsPage.test.tsx index db9d25d886..0628c38d78 100644 --- a/ui/litellm-dashboard/src/components/AccessGroups/AccessGroupsDetailsPage.test.tsx +++ b/ui/litellm-dashboard/src/components/AccessGroups/AccessGroupsDetailsPage.test.tsx @@ -56,7 +56,7 @@ const createMockAccessGroup = ( access_group_id: "ag-1", access_group_name: "Test Group", description: "A test access group", - access_model_ids: ["model-1", "model-2"], + access_model_names: ["model-1", "model-2"], access_mcp_server_ids: ["mcp-1"], access_agent_ids: ["agent-1"], assigned_team_ids: ["team-1"], @@ -319,7 +319,7 @@ describe("AccessGroupDetail", () => { it("should show empty state in Models tab when no models assigned", () => { mockUseAccessGroupDetails.mockReturnValue({ ...baseMockReturnValue, - data: createMockAccessGroup({ access_model_ids: [] }), + data: createMockAccessGroup({ access_model_names: [] }), } as ReturnType); renderWithProviders( diff --git a/ui/litellm-dashboard/src/components/AccessGroups/AccessGroupsDetailsPage.tsx b/ui/litellm-dashboard/src/components/AccessGroups/AccessGroupsDetailsPage.tsx index 9b794959ba..7db2e338cf 100644 --- a/ui/litellm-dashboard/src/components/AccessGroups/AccessGroupsDetailsPage.tsx +++ b/ui/litellm-dashboard/src/components/AccessGroups/AccessGroupsDetailsPage.tsx @@ -83,7 +83,7 @@ export function AccessGroupDetail({ ); } - const modelIds = accessGroup.access_model_ids ?? []; + const modelIds = accessGroup.access_model_names ?? []; const mcpServerIds = accessGroup.access_mcp_server_ids ?? []; const agentIds = accessGroup.access_agent_ids ?? []; const keyIds = accessGroup.assigned_key_ids ?? []; diff --git a/ui/litellm-dashboard/src/components/AccessGroups/AccessGroupsModal/AccessGroupCreateModal.tsx b/ui/litellm-dashboard/src/components/AccessGroups/AccessGroupsModal/AccessGroupCreateModal.tsx index 0f606d9972..b51fbd5bd9 100644 --- a/ui/litellm-dashboard/src/components/AccessGroups/AccessGroupsModal/AccessGroupCreateModal.tsx +++ b/ui/litellm-dashboard/src/components/AccessGroups/AccessGroupsModal/AccessGroupCreateModal.tsx @@ -30,7 +30,7 @@ export function AccessGroupCreateModal({ const params: AccessGroupCreateParams = { access_group_name: values.name, description: values.description, - access_model_ids: values.modelIds, + access_model_names: values.modelIds, access_mcp_server_ids: values.mcpServerIds, access_agent_ids: values.agentIds, }; diff --git a/ui/litellm-dashboard/src/components/AccessGroups/AccessGroupsModal/AccessGroupEditModal.tsx b/ui/litellm-dashboard/src/components/AccessGroups/AccessGroupsModal/AccessGroupEditModal.tsx index c21b1351b5..919295b6f7 100644 --- a/ui/litellm-dashboard/src/components/AccessGroups/AccessGroupsModal/AccessGroupEditModal.tsx +++ b/ui/litellm-dashboard/src/components/AccessGroups/AccessGroupsModal/AccessGroupEditModal.tsx @@ -32,7 +32,7 @@ export function AccessGroupEditModal({ form.setFieldsValue({ name: accessGroup.access_group_name, description: accessGroup.description ?? "", - modelIds: accessGroup.access_model_ids ?? [], + modelIds: accessGroup.access_model_names ?? [], mcpServerIds: accessGroup.access_mcp_server_ids ?? [], agentIds: accessGroup.access_agent_ids ?? [], }); @@ -46,7 +46,7 @@ export function AccessGroupEditModal({ const params: AccessGroupUpdateParams = { access_group_name: values.name, description: values.description, - access_model_ids: values.modelIds, + access_model_names: values.modelIds, access_mcp_server_ids: values.mcpServerIds, access_agent_ids: values.agentIds, }; diff --git a/ui/litellm-dashboard/src/components/AccessGroups/AccessGroupsPage.test.tsx b/ui/litellm-dashboard/src/components/AccessGroups/AccessGroupsPage.test.tsx index dd38cd61d9..6aa35f349d 100644 --- a/ui/litellm-dashboard/src/components/AccessGroups/AccessGroupsPage.test.tsx +++ b/ui/litellm-dashboard/src/components/AccessGroups/AccessGroupsPage.test.tsx @@ -9,7 +9,7 @@ const mockAccessGroups: AccessGroupResponse[] = [ access_group_id: "ag-1", access_group_name: "Admin Group", description: "Administrators with full access", - access_model_ids: ["m1", "m2"], + access_model_names: ["m1", "m2"], access_mcp_server_ids: ["s1"], access_agent_ids: ["a1"], assigned_team_ids: [], @@ -23,7 +23,7 @@ const mockAccessGroups: AccessGroupResponse[] = [ access_group_id: "ag-2", access_group_name: "Read Only", description: "Read-only access to models", - access_model_ids: ["m1"], + access_model_names: ["m1"], access_mcp_server_ids: [], access_agent_ids: [], assigned_team_ids: [], diff --git a/ui/litellm-dashboard/src/components/AccessGroups/AccessGroupsPage.tsx b/ui/litellm-dashboard/src/components/AccessGroups/AccessGroupsPage.tsx index 22f093b6fa..8aca22bd36 100644 --- a/ui/litellm-dashboard/src/components/AccessGroups/AccessGroupsPage.tsx +++ b/ui/litellm-dashboard/src/components/AccessGroups/AccessGroupsPage.tsx @@ -59,7 +59,7 @@ function mapResponseToAccessGroup(r: AccessGroupResponse): AccessGroup { id: r.access_group_id, name: r.access_group_name, description: r.description ?? "", - modelIds: r.access_model_ids, + modelIds: r.access_model_names, mcpServerIds: r.access_mcp_server_ids, agentIds: r.access_agent_ids, keyIds: r.assigned_key_ids, @@ -199,29 +199,32 @@ export function AccessGroupsPage() { enableSorting: false, cell: ({ row }) => { const record = row.original; + const modelIds = record.modelIds ?? []; + const mcpServerIds = record.mcpServerIds ?? []; + const agentIds = record.agentIds ?? []; return ( - + - {record.modelIds.length} + {modelIds.length} - + - {record.mcpServerIds.length} + {mcpServerIds.length} - + - {record.agentIds.length} + {agentIds.length} diff --git a/ui/litellm-dashboard/tsconfig.json b/ui/litellm-dashboard/tsconfig.json index 5b0352feb9..d24bdd340f 100644 --- a/ui/litellm-dashboard/tsconfig.json +++ b/ui/litellm-dashboard/tsconfig.json @@ -14,7 +14,7 @@ "moduleResolution": "bundler", "resolveJsonModule": true, "isolatedModules": true, - "jsx": "preserve", + "jsx": "react-jsx", "incremental": true, "plugins": [ {