diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index c22bb76629d..fabbb54952c 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -280,6 +280,12 @@ class KeyManagementRoutes(str, enum.Enum): # `access_group_ids` on keys they create/update. Default-deny. KEY_ACCESS_GROUP_ASSIGNMENT = "/key/access_group_assignment" + # Field-level opt-in permission (not a real HTTP route). When present in a + # team's `team_member_permissions`, non-admin members of that team may create, + # update and delete the team's auto-routers (`litellm_params.model` under + # `auto_router/`) through the model management endpoints. Default-deny. + AUTO_ROUTER_MANAGEMENT = "/model/auto_router_management" + # info and health routes KEY_INFO = "/key/info" KEY_HEALTH = "/key/health" @@ -642,6 +648,7 @@ class LiteLLMRoutes(enum.Enum): KeyManagementRoutes.KEY_RESET_SPEND.value, KeyManagementRoutes.KEY_ALIASES.value, KeyManagementRoutes.KEY_ACCESS_GROUP_ASSIGNMENT.value, + KeyManagementRoutes.AUTO_ROUTER_MANAGEMENT.value, ] management_routes = ( diff --git a/litellm/proxy/management_endpoints/auto_router_endpoints.py b/litellm/proxy/management_endpoints/auto_router_endpoints.py index bbc914a772a..9fca29aa159 100644 --- a/litellm/proxy/management_endpoints/auto_router_endpoints.py +++ b/litellm/proxy/management_endpoints/auto_router_endpoints.py @@ -41,6 +41,7 @@ from litellm.repositories.base_repository import SupportsModelDump from litellm.repositories.team_repository import TeamRepository from litellm.router_strategy.complexity_router import ComplexityRouter from litellm.router_utils.auto_router_model_naming import ( + AUTO_ROUTER_MODEL_PREFIX, StrategyRouterDependencyRole, classify_strategy_router_model, strategy_router_dependencies, @@ -65,6 +66,7 @@ from litellm.types.management_endpoints.auto_router_endpoints import ( ShadowEvalTargetType, StartShadowEvalRequest, ) +from litellm.types.router import ModelInfo, updateDeployment, updateLiteLLMParams if TYPE_CHECKING: from fastapi import APIRouter, Depends, HTTPException, Query, status @@ -200,14 +202,16 @@ async def _authorize_router_dry_run(user_api_key_dict: UserAPIKeyAuth, team_id: """Allow exactly the callers who could create this router. Both dry runs are gated like the write they rehearse rather than as reads: a proxy - admin, or a team admin naming their own team, matching /model/new. Routing a test + admin, a team admin naming their own team, or a team member whose team grants + auto-router management, matching /model/new for a complexity router. Routing a test prompt can also spend money (an `llm` classifier config calls its classifier, a semantic config embeds the prompt), so a read-level gate would be too loose anyway. """ from litellm.proxy.management_endpoints.model_management_endpoints import ( ModelManagementAuthChecks, + ModelWrite, ) - from litellm.proxy.proxy_server import premium_user, prisma_client + from litellm.proxy.proxy_server import llm_router, premium_user, prisma_client if user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN: return @@ -239,11 +243,22 @@ async def _authorize_router_dry_run(user_api_key_dict: UserAPIKeyAuth, team_id: }, ) + # The create this rehearses: a new complexity router. A dry run names nothing, so the + # rehearsal carries no model_name and the name guard has nothing to judge. + rehearsed_create: Final = ModelWrite( + stored=None, + incoming=updateDeployment( + litellm_params=updateLiteLLMParams(model=f"{AUTO_ROUTER_MODEL_PREFIX}complexity_router"), + model_info=ModelInfo(team_id=team_id), + ), + ) ModelManagementAuthChecks.can_user_make_team_model_call( team_id=team_id, user_api_key_dict=user_api_key_dict, team_obj=LiteLLM_TeamTable.model_validate(team_row.model_dump()), premium_user=premium_user, + member_write=rehearsed_create, + llm_router=llm_router, ) diff --git a/litellm/proxy/management_endpoints/common_utils.py b/litellm/proxy/management_endpoints/common_utils.py index 98155ad6839..5038bc14af2 100644 --- a/litellm/proxy/management_endpoints/common_utils.py +++ b/litellm/proxy/management_endpoints/common_utils.py @@ -176,7 +176,7 @@ async def _is_user_org_admin_for_team(user_api_key_dict: UserAPIKeyAuth, team_ob return False -def _team_member_has_permission( +def team_member_has_permission( user_api_key_dict: UserAPIKeyAuth, team_obj: LiteLLM_TeamTable, permission: str, diff --git a/litellm/proxy/management_endpoints/key_management_endpoints.py b/litellm/proxy/management_endpoints/key_management_endpoints.py index 749a940de0e..5d2b4f65ba3 100644 --- a/litellm/proxy/management_endpoints/key_management_endpoints.py +++ b/litellm/proxy/management_endpoints/key_management_endpoints.py @@ -88,8 +88,8 @@ from litellm.proxy.management_endpoints.common_utils import ( _is_user_org_admin_for_team, _is_user_team_admin, _set_object_metadata_field, - _team_member_has_permission, _user_has_admin_view, + team_member_has_permission, validate_budget_duration, validate_finite_spend, ) @@ -5889,7 +5889,7 @@ def _get_team_ids_with_key_list_permission_from_objects( team.team_id for team in team_objects if not _is_user_team_admin(user_api_key_dict=user_api_key_dict, team_obj=team) - and _team_member_has_permission( + and team_member_has_permission( user_api_key_dict=user_api_key_dict, team_obj=team, permission=KeyManagementRoutes.KEY_LIST.value, diff --git a/litellm/proxy/management_endpoints/model_management_endpoints.py b/litellm/proxy/management_endpoints/model_management_endpoints.py index 3ca749855ee..5b89d48c9ce 100644 --- a/litellm/proxy/management_endpoints/model_management_endpoints.py +++ b/litellm/proxy/management_endpoints/model_management_endpoints.py @@ -15,6 +15,7 @@ import datetime import json from collections.abc import AsyncGenerator, Awaitable, Callable, Mapping, Sequence from contextlib import AbstractAsyncContextManager, asynccontextmanager +from dataclasses import dataclass from json import JSONDecodeError from types import MappingProxyType from typing import TYPE_CHECKING, Annotated, Final, Literal, Protocol, TypeVar, cast @@ -22,6 +23,7 @@ from typing import TYPE_CHECKING, Annotated, Final, Literal, Protocol, TypeVar, from fastapi import APIRouter, Depends, Header, HTTPException, Request, status from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator +import litellm from litellm._logging import verbose_proxy_logger from litellm._uuid import uuid from litellm.constants import LITELLM_PROXY_ADMIN_NAME @@ -37,6 +39,7 @@ from litellm.litellm_core_utils.ptu_pricing import ( from litellm.proxy._types import ( BlockModelRequest, CommonProxyErrors, + KeyManagementRoutes, LiteLLM_ProxyModelTable, LiteLLM_TeamTable, LitellmTableNames, @@ -46,10 +49,10 @@ from litellm.proxy._types import ( ProxyErrorTypes, ProxyException, ReconcileOutcome, - TeamModelAddRequest, - TeamModelDeleteRequest, + SpecialModelNames, UserAPIKeyAuth, ) +from litellm.proxy.auth.auth_checks import get_team_object from litellm.proxy.auth.litellm_license import AUTO_ROUTER_LICENSE_REMEDY from litellm.proxy.auth.user_api_key_auth import user_api_key_auth from litellm.proxy.common_utils.config_sync_pubsub import ( @@ -62,11 +65,11 @@ from litellm.proxy.common_utils.encrypt_decrypt_utils import ( ) from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache from litellm.proxy.db.routing_prisma_wrapper import WriterPinnedClient -from litellm.proxy.management_endpoints.common_utils import _is_user_team_admin +from litellm.proxy.management_endpoints.common_utils import _is_user_team_admin, team_member_has_permission from litellm.proxy.management_endpoints.team_endpoints import ( _refresh_cached_team, - team_model_add, - team_model_delete, + add_models_to_team, + remove_models_from_team, ) from litellm.proxy.management_endpoints.team_endpoints import ( update_team as _legacy_update_team, @@ -101,8 +104,10 @@ from litellm.router_strategy.complexity_router import ( from litellm.router_utils.auto_router_model_naming import ( GATED_AUTO_ROUTER_CAPABILITIES, STRATEGY_ROUTER_PARAM_FIELDS, + StrategyRouterKind, capability_limit_violation, carries_complexity_router_settings, + classify_strategy_router_model, count_capability_routers, gated_capability_of, is_complexity_router_model, @@ -339,6 +344,162 @@ def _effective_model( return decrypted if isinstance(decrypted, str) else None +@dataclass(frozen=True, slots=True) +class ModelWrite: + """The deployment a write touches (``stored``, None on create) and what the caller sends (None on delete).""" + + stored: Deployment | None + incoming: Deployment | updateDeployment | None + + def kinds(self) -> tuple[StrategyRouterKind | None, StrategyRouterKind | None]: + """(stored kind, kind the write leaves), each None for a regular model.""" + stored_params: Final = None if self.stored is None else self.stored.litellm_params + incoming_params: Final = None if self.incoming is None else self.incoming.litellm_params + stored_model: Final = _effective_model(None, stored_params) + effective_model: Final = _effective_model(incoming_params, stored_params) + return ( + None if stored_model is None else classify_strategy_router_model(stored_model), + None if effective_model is None else classify_strategy_router_model(effective_model), + ) + + def registered_name(self) -> str | None: + """The public name this write puts on ``team.models``, None when it keeps the stored one. + + A create registers its ``model_name``; a rehearsed create with no name (a dry run) registers + nothing. On an existing row the rename channel is judged by the same owner the update path + uses (``_get_public_model_name``), so a dashboard save that echoes the current public name or + the internal ``model_name_{team}_{uuid}`` key is not a rename, and neither a config-only + update nor a delete registers anything. + """ + if self.incoming is None: + return None + if self.stored is None: + return self.incoming.model_name + if not isinstance(self.incoming, updateDeployment): + return None if self.incoming.model_name == self.stored.model_name else self.incoming.model_name + public_name: Final = _get_public_model_name(patch_data=self.incoming, db_model=self.stored) + current_public_name: Final = self.stored.model_info.team_public_model_name or self.stored.model_name + return None if public_name == current_public_name else public_name + + def moves_team(self) -> bool: + """Whether the write re-homes an existing row onto a different team.""" + if self.stored is None or self.incoming is None or self.incoming.model_info is None: + return False + target: Final = self.incoming.model_info.team_id + return target is not None and target != self.stored.model_info.team_id + + def renames_outside_the_rename_channel(self) -> bool: + """Whether the write changes the stored ``model_info.team_public_model_name`` directly. + + ``update_db_model`` merges the payload's ``model_info`` into the row, so that field is a + second way to change the name the router serves the team by, and it bypasses both the + collision check and the team list bookkeeping that ``model_name`` goes through. Echoing + the current value, which every dashboard save does, is not a change. + """ + if self.stored is None or self.incoming is None or self.incoming.model_info is None: + return False + candidate: Final = self.incoming.model_info.team_public_model_name + return candidate is not None and candidate != self.stored.model_info.team_public_model_name + + def renames_off_team(self) -> bool: + """Whether the write renames a team row without naming its team. + + The team update path treats a rename as a public rename only when the payload carries + ``model_info.team_id``; without it the new ``model_name`` lands on the internal routing key + and ``team.models`` never learns the name. A member's rename must go the route the team + list can follow. + """ + if ( + self.stored is None + or self.stored.model_info.team_id is None + or not isinstance(self.incoming, updateDeployment) + ): + return False + scoped: Final = self.incoming.model_info is not None and self.incoming.model_info.team_id is not None + return not scoped and self.registered_name() is not None + + def created_by(self) -> str | None: + """The row column the create path wrote, never the caller-writable ``model_info.created_by``.""" + value: Final = None if self.stored is None else (self.stored.model_extra or {}).get("created_by") + return value if isinstance(value, str) else None + + def reads_server_file(self) -> bool: + """Whether the deployment this write leaves names a file on the proxy host. + + ``auto_router_config_path`` is opened at router init, so it is a file-read primitive; a + delegated write must configure its router inline. + """ + return any( + params is not None and params.auto_router_config_path is not None + for params in ( + None if self.incoming is None else self.incoming.litellm_params, + None if self.stored is None else self.stored.litellm_params, + ) + ) + + +def _name_grants_extra_access(public_name: str | None, llm_router: Router | None) -> bool: + """Whether registering ``public_name`` on ``team.models`` would grant models beyond the router itself. + + Two families. Names the team access check reads as a grant on their own: a wildcard, the + all-models sentinel, an access group name, a global ``litellm.model_alias_map`` alias. And every + identifier the request dispatch in ``route_llm_request`` can resolve: the serving path's own + predicate (``Router.is_recognized_model``: model names, deployment ids, ``model_group_alias``, + routing groups), the team-scoped public names it resolves through ``map_team_model``, and the two + fallbacks it leaves to callers, a wildcard deployment that would match the name and a raw + ``litellm_params.model`` deployment name. Such a grant can outlive the router, since unbacked name + cleanup keeps names the router still serves. Without a router to ask, nothing can be cleared. + """ + if public_name is None or llm_router is None: + return True + if "*" in public_name or public_name == SpecialModelNames.all_proxy_models.value: + return True + if litellm.model_alias_map and public_name in litellm.model_alias_map: + return True + return ( + llm_router.is_recognized_model(public_name) + or public_name in llm_router.team_public_model_names + or public_name in llm_router.get_model_access_groups() + or bool(llm_router.pattern_router.route(public_name)) + or public_name in llm_router.deployment_names + ) + + +# The model write routes are self-managed, so route RBAC admits view-only roles; the grant is a +# write and must not reach them. +_VIEW_ONLY_ROLES: Final = frozenset({LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY, LitellmUserRoles.INTERNAL_USER_VIEW_ONLY}) + + +def member_may_write_auto_router( + user_api_key_dict: UserAPIKeyAuth, team_obj: LiteLLM_TeamTable, write: ModelWrite, llm_router: Router | None +) -> bool: + """The whole shape the auto-router member permission covers, judged on the write's RESULT. + + The member must hold a write-capable role and the team grant; the row must be a strategy router before and after the + write (so neither a router nor a regular model can be converted), configured inline rather than + from a file on the proxy host, and stay on its team under its public name unless renamed + through ``model_name`` with the team named; an existing row must be one the member created; and a + public name the write registers must not widen the team's model access. + """ + if user_api_key_dict.user_role in _VIEW_ONLY_ROLES or not team_member_has_permission( + user_api_key_dict=user_api_key_dict, + team_obj=team_obj, + permission=KeyManagementRoutes.AUTO_ROUTER_MANAGEMENT.value, + ): + return False + stored_kind, effective_kind = write.kinds() + if effective_kind is None or (write.stored is not None and stored_kind is None) or write.reads_server_file(): + return False + if write.moves_team() or write.renames_outside_the_rename_channel() or write.renames_off_team(): + return False + if write.stored is not None and ( + user_api_key_dict.user_id is None or write.created_by() != user_api_key_dict.user_id + ): + return False + registered_name: Final = write.registered_name() + return registered_name is None or not _name_grants_extra_access(registered_name, llm_router) + + def _effective_complexity_router_params( incoming_params: GenericLiteLLMParams | None, existing_params: GenericLiteLLMParams | None ) -> Mapping[str, object]: @@ -880,6 +1041,8 @@ async def patch_model( user_api_key_dict=user_api_key_dict, prisma_client=prisma_client, premium_user=premium_user, + member_write=ModelWrite(stored=db_model, incoming=patch_data), + llm_router=llm_router, ) # Pause/resume (`blocked`) is a proxy-admin-only privilege. Team admins @@ -1252,18 +1415,43 @@ async def _add_team_model_to_db( ) if original_model_name: - await team_model_add( - data=TeamModelAddRequest( - team_id=_team_id, - models=[original_model_name], - ), - http_request=Request(scope={"type": "http"}), - user_api_key_dict=user_api_key_dict, - ) + await _register_team_models(_team_id, (original_model_name,), prisma_client) return model_response +async def _register_team_models(team_id: str, models: Sequence[str], prisma_client: PrismaClient) -> None: + """Register public names on the team after the caller's own auth check on the deployment passed.""" + from litellm.proxy.proxy_server import proxy_logging_obj, user_api_key_cache + + await add_models_to_team( + team_id=team_id, + models=models, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + proxy_logging_obj=proxy_logging_obj, + ) + + +async def _unregister_team_models(team_id: str, models: Sequence[str], prisma_client: PrismaClient) -> None: + from litellm.proxy.proxy_server import proxy_logging_obj, user_api_key_cache + + team_obj: Final = await get_team_object( + team_id=team_id, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + proxy_logging_obj=proxy_logging_obj, + check_db_only=True, + ) + await remove_models_from_team( + team_obj=team_obj, + models=models, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + proxy_logging_obj=proxy_logging_obj, + ) + + async def _update_team_model_in_db( db_model: Deployment, patch_data: updateDeployment, @@ -1283,13 +1471,15 @@ async def _update_team_model_in_db( refused or failed write leaves the team as it was (the create path orders itself the same way). """ # Validate team_id if present in patch_data - from litellm.proxy.proxy_server import premium_user + from litellm.proxy.proxy_server import llm_router, premium_user await ModelManagementAuthChecks.allow_team_model_action( model_params=patch_data, user_api_key_dict=user_api_key_dict, prisma_client=prisma_client, premium_user=premium_user, + member_write=ModelWrite(stored=db_model, incoming=patch_data), + llm_router=llm_router, ) # Validated before the row write, beside the premium check the create path already runs here. @@ -1339,14 +1529,13 @@ async def _update_team_model_in_db( await _setup_new_team_model_assignment( team_id=patch_team_id, public_model_name=public_model_name, - user_api_key_dict=user_api_key_dict, + prisma_client=prisma_client, ) else: await _update_existing_team_model_assignment( team_id=patch_team_id, public_model_name=public_model_name, db_model=db_model, - user_api_key_dict=user_api_key_dict, prisma_client=prisma_client, ) @@ -1402,17 +1591,10 @@ def _get_public_model_name( async def _setup_new_team_model_assignment( team_id: str, public_model_name: str, - user_api_key_dict: UserAPIKeyAuth, + prisma_client: PrismaClient, ) -> None: """Register a newly team-assigned model's public name on the team.""" - await team_model_add( - data=TeamModelAddRequest( - team_id=team_id, - models=[public_model_name], - ), - http_request=Request(scope={"type": "http"}), - user_api_key_dict=user_api_key_dict, - ) + await _register_team_models(team_id, (public_model_name,), prisma_client) async def _get_team_deployments( @@ -1597,7 +1779,6 @@ async def _update_existing_team_model_assignment( team_id: str, public_model_name: str, db_model: Deployment, - user_api_key_dict: UserAPIKeyAuth, prisma_client: PrismaClient | None, ) -> None: """Update an existing team model if the public name changed. @@ -1635,37 +1816,16 @@ async def _update_existing_team_model_assignment( ] # Add new name first, then delete old name to prevent access loss on partial failure - await team_model_add( - data=TeamModelAddRequest( - team_id=team_id, - models=[public_model_name], - ), - http_request=Request(scope={"type": "http"}), - user_api_key_dict=user_api_key_dict, - ) + await _register_team_models(team_id, (public_model_name,), prisma_client) if not other_deployments_with_old_name: - await team_model_delete( - data=TeamModelDeleteRequest( - team_id=team_id, - models=[old_public_name], - ), - http_request=Request(scope={"type": "http"}), - user_api_key_dict=user_api_key_dict, - ) + await _unregister_team_models(team_id, (old_public_name,), prisma_client) elif not old_public_name and public_model_name: # First-time assignment of public name on an existing team deployment: # ensure the team's models list is updated so team routing can resolve it. - await team_model_add( - data=TeamModelAddRequest( - team_id=team_id, - models=[public_model_name], - ), - http_request=Request(scope={"type": "http"}), - user_api_key_dict=user_api_key_dict, - ) + await _register_team_models(team_id, (public_model_name,), prisma_client) # else: old_public_name == public_model_name (no rename needed) - # No team_model_add/delete calls required; public name is already registered + # No team list writes required; public name is already registered class ModelManagementAuthChecks: @@ -1679,7 +1839,12 @@ class ModelManagementAuthChecks: user_api_key_dict: UserAPIKeyAuth, team_obj: LiteLLM_TeamTable | None = None, premium_user: bool = False, + member_write: ModelWrite | None = None, + llm_router: Router | None = None, ) -> Literal[True]: + """``member_write`` is the write a non-admin team member asks for, judged by + ``member_may_write_auto_router``; callers that pass none keep the team-admin-only verdict. + """ if premium_user is False: raise HTTPException( status_code=403, @@ -1687,14 +1852,20 @@ class ModelManagementAuthChecks: ) if user_api_key_dict.user_role and user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN: return True - elif team_obj is None or not _is_user_team_admin(user_api_key_dict=user_api_key_dict, team_obj=team_obj): - raise HTTPException( - status_code=403, - detail={ - "error": f"Team ID={team_id} does not match the API key's team ID={user_api_key_dict.team_id}, OR you are not the admin for this team. Check `/user/info` to verify your team admin status." - }, - ) - return True + if team_obj is not None and _is_user_team_admin(user_api_key_dict=user_api_key_dict, team_obj=team_obj): + return True + if ( + team_obj is not None + and member_write is not None + and member_may_write_auto_router(user_api_key_dict, team_obj, member_write, llm_router) + ): + return True + raise HTTPException( + status_code=403, + detail={ + "error": f"Team ID={team_id} does not match the API key's team ID={user_api_key_dict.team_id}, OR you are not the admin for this team. Check `/user/info` to verify your team admin status. Team members may create auto-routers, and manage the ones they created, when the team grants the '{KeyManagementRoutes.AUTO_ROUTER_MANAGEMENT.value}' member permission." + }, + ) @staticmethod def can_user_attach_credential( @@ -1728,6 +1899,8 @@ class ModelManagementAuthChecks: user_api_key_dict: UserAPIKeyAuth, prisma_client: PrismaClient, premium_user: bool, + member_write: ModelWrite | None = None, + llm_router: Router | None = None, ) -> Literal[True]: if model_params.model_info is None or model_params.model_info.team_id is None: return True @@ -1753,6 +1926,8 @@ class ModelManagementAuthChecks: user_api_key_dict=user_api_key_dict, team_obj=existing_team_row, premium_user=premium_user, + member_write=member_write, + llm_router=llm_router, ) return True @@ -1763,6 +1938,8 @@ class ModelManagementAuthChecks: prisma_client: PrismaClient, premium_user: bool, allow_missing_team: bool = False, + member_write: ModelWrite | None = None, + llm_router: Router | None = None, ) -> Literal[True]: ## Check team model auth if model_params.model_info is not None and model_params.model_info.team_id is not None: @@ -1791,6 +1968,8 @@ class ModelManagementAuthChecks: user_api_key_dict=user_api_key_dict, team_obj=team_obj, premium_user=premium_user, + member_write=member_write, + llm_router=llm_router, ) ## Check non-team model auth elif user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN: @@ -1859,6 +2038,8 @@ async def delete_model( prisma_client=prisma_client, premium_user=premium_user, allow_missing_team=True, + member_write=ModelWrite(stored=model_params, incoming=None), + llm_router=llm_router, ) # update DB @@ -2026,6 +2207,7 @@ async def add_new_model( """ from litellm.proxy.proxy_server import ( general_settings, + llm_router, premium_user, prisma_client, proxy_config, @@ -2048,6 +2230,8 @@ async def add_new_model( user_api_key_dict=user_api_key_dict, prisma_client=prisma_client, premium_user=premium_user, + member_write=ModelWrite(stored=None, incoming=model_params), + llm_router=llm_router, ) ModelManagementAuthChecks.can_user_attach_credential( @@ -2231,6 +2415,8 @@ async def update_model( user_api_key_dict=user_api_key_dict, prisma_client=prisma_client, premium_user=premium_user, + member_write=ModelWrite(stored=deployment, incoming=model_params), + llm_router=llm_router, ) ModelManagementAuthChecks.can_user_attach_credential( diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py index c050368b3fe..91c02b889d0 100644 --- a/litellm/proxy/management_endpoints/team_endpoints.py +++ b/litellm/proxy/management_endpoints/team_endpoints.py @@ -102,10 +102,10 @@ from litellm.proxy.management_endpoints.common_utils import ( _is_user_org_admin_for_team, _is_user_team_admin, _set_object_metadata_field, - _team_member_has_permission, _update_metadata_fields, _upsert_budget_and_membership, _user_has_admin_view, + team_member_has_permission, validate_budget_duration, ) from litellm.proxy.management_endpoints.organization_endpoints import ( @@ -5573,11 +5573,28 @@ async def team_model_add( detail={"error": "Only proxy admin or team admin can modify team models"}, ) + return await add_models_to_team( + team_id=data.team_id, + models=data.models, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + proxy_logging_obj=proxy_logging_obj, + ) + + +async def add_models_to_team( + team_id: str, + models: Sequence[str], + prisma_client: PrismaClient, + user_api_key_cache: UserApiKeyCache, + proxy_logging_obj: ProxyLogging, +) -> "prisma_models.LiteLLM_TeamTable": + """Register public model names on a team. The caller owns the authorization decision.""" # Atomic array append with dedup at the database level so concurrent # BYOK model creates don't overwrite each other's team.models entries. # When the team currently has models=[] (unrestricted access), the # CASE expression inserts the 'all-proxy-models' sentinel first. - models_to_add: Final = list(data.models) + models_to_add: Final = list(models) await prisma_client.db.execute_raw( 'UPDATE "LiteLLM_TeamTable" ' "SET models = (" @@ -5590,7 +5607,7 @@ async def team_model_add( ") " "WHERE team_id = $2", models_to_add, - data.team_id, + team_id, ) # Re-fetch via update (write-routed) instead of find_unique (read-routed) # to avoid returning stale data from a read replica. The models column @@ -5599,14 +5616,14 @@ async def team_model_add( # `include` mirrors the relations the auth path consumes off the cached # team object so that `_refresh_cached_team` doesn't null them out. updated_team: Final = await _team_db(prisma_client).update( - where={"team_id": data.team_id}, + where={"team_id": team_id}, data={"updated_at": datetime.now(timezone.utc)}, include={"litellm_model_table": True, "object_permission": True}, ) if updated_team is None: raise HTTPException( status_code=404, - detail={"error": f"Team not found, passed team_id={data.team_id}"}, + detail={"error": f"Team not found, passed team_id={team_id}"}, ) await _refresh_cached_team( @@ -5678,22 +5695,36 @@ async def team_model_delete( detail={"error": "Only proxy admin or team admin can modify team models"}, ) - # Get current models list + return await remove_models_from_team( + team_obj=team_obj, + models=data.models, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + proxy_logging_obj=proxy_logging_obj, + ) + + +async def remove_models_from_team( + team_obj: LiteLLM_TeamTable, + models: Sequence[str], + prisma_client: PrismaClient, + user_api_key_cache: UserApiKeyCache, + proxy_logging_obj: ProxyLogging, +) -> "prisma_models.LiteLLM_TeamTable": + """Drop public model names from a team. The caller owns the authorization decision.""" current_models: Final[Sequence[str]] = team_obj.models or [] + updated_models: Final = [m for m in current_models if m not in models] - # Remove specified models - updated_models: Final = [m for m in current_models if m not in data.models] - - # Update team. See team_model_add for the rationale on `include`. + # See add_models_to_team for the rationale on `include`. updated_team: Final = await _team_db(prisma_client).update( - where={"team_id": data.team_id}, + where={"team_id": team_obj.team_id}, data={"models": updated_models}, include={"litellm_model_table": True, "object_permission": True}, ) if updated_team is None: raise HTTPException( status_code=404, - detail={"error": f"Team not found, passed team_id={data.team_id}"}, + detail={"error": f"Team not found, passed team_id={team_obj.team_id}"}, ) await _refresh_cached_team( @@ -6053,7 +6084,7 @@ async def _resolve_team_daily_activity_scope( for team_alias in team_aliases: team_obj = LiteLLM_TeamTable.model_validate(team_alias.model_dump()) is_admin = _is_user_team_admin(user_api_key_dict=user_api_key_dict, team_obj=team_obj) - has_perm = _team_member_has_permission( + has_perm = team_member_has_permission( user_api_key_dict=user_api_key_dict, team_obj=team_obj, permission="/team/daily/activity", diff --git a/litellm/proxy/spend_tracking/spend_management_endpoints.py b/litellm/proxy/spend_tracking/spend_management_endpoints.py index f8831ca4152..ca552f939ec 100644 --- a/litellm/proxy/spend_tracking/spend_management_endpoints.py +++ b/litellm/proxy/spend_tracking/spend_management_endpoints.py @@ -4590,7 +4590,7 @@ async def _can_team_member_view_log( """ from litellm.proxy.management_endpoints.common_utils import ( _is_user_team_admin, - _team_member_has_permission, + team_member_has_permission, ) if team_id is None: @@ -4601,7 +4601,7 @@ async def _can_team_member_view_log( team_obj: Final = LiteLLM_TeamTable.model_validate(team_row.model_dump()) if _is_user_team_admin(user_api_key_dict=user_api_key_dict, team_obj=team_obj): return True - return _team_member_has_permission( + return team_member_has_permission( user_api_key_dict=user_api_key_dict, team_obj=team_obj, permission=KeyManagementRoutes.SPEND_LOGS.value, @@ -4669,7 +4669,7 @@ async def _get_permitted_team_ids_for_spend_logs( from litellm.proxy.auth.auth_checks import get_user_object from litellm.proxy.management_endpoints.common_utils import ( _is_user_team_admin, - _team_member_has_permission, + team_member_has_permission, ) from litellm.proxy.proxy_server import proxy_logging_obj, user_api_key_cache @@ -4688,7 +4688,7 @@ async def _get_permitted_team_ids_for_spend_logs( permitted: Final[list[str]] = [] for team_row in team_rows: team_obj = LiteLLM_TeamTable.model_validate(team_row.model_dump()) - if _is_user_team_admin(user_api_key_dict=user_api_key_dict, team_obj=team_obj) or _team_member_has_permission( + if _is_user_team_admin(user_api_key_dict=user_api_key_dict, team_obj=team_obj) or team_member_has_permission( user_api_key_dict=user_api_key_dict, team_obj=team_obj, permission=KeyManagementRoutes.SPEND_LOGS.value, diff --git a/tests/test_litellm/proxy/management_endpoints/test_auto_router_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_auto_router_endpoints.py index dc18e0f7d4a..24fa80b8f5e 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_auto_router_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_auto_router_endpoints.py @@ -2633,6 +2633,62 @@ async def test_validate_config_gates_like_the_write_it_rehearses(monkeypatch: py assert not_their_team.value.status_code == 403 +@pytest.mark.asyncio +async def test_dry_runs_admit_a_member_whose_team_grants_auto_router_management(monkeypatch: pytest.MonkeyPatch): + """The dry runs rehearse a complexity-router write, so the member permission that opens + that write opens both dry runs too, and only that permission does.""" + from litellm.proxy import proxy_server + from litellm.proxy.management_endpoints.auto_router_endpoints import ( + preview_auto_router_routing, + validate_complexity_router_config, + ) + from litellm.types.management_endpoints.auto_router_endpoints import ( + AutoRouterRoutingTestRequest, + ComplexityRouterConfigValidationRequest, + ) + + def _team_prisma(permissions: list[str]) -> MagicMock: + row_data = { + "team_id": "team-1", + "members_with_roles": [{"role": "admin", "user_id": "team-admin"}, {"role": "user", "user_id": "member"}], + "team_member_permissions": permissions, + "models": ["cheap-model", "mid-model", "big-model"], + } + team_row = MagicMock() + team_row.model_dump.return_value = row_data + team_row.dict.return_value = row_data + prisma = MagicMock() + prisma.db.litellm_teamtable.find_unique = AsyncMock(return_value=team_row) + return prisma + + member: Final = UserAPIKeyAuth(user_role=LitellmUserRoles.INTERNAL_USER, api_key="sk-member", user_id="member") + config: Final = {"tiers": TIERS, "classifier_type": "heuristic"} + monkeypatch.setattr(proxy_server, "premium_user", True) + monkeypatch.setattr(proxy_server, "llm_router", _router()) + + monkeypatch.setattr(proxy_server, "prisma_client", _team_prisma(["/key/generate"])) + with pytest.raises(HTTPException) as ungranted: + await validate_complexity_router_config( + ComplexityRouterConfigValidationRequest(complexity_router_config=config, team_id="team-1"), member + ) + assert ungranted.value.status_code == 403 + + monkeypatch.setattr(proxy_server, "prisma_client", _team_prisma(["/model/auto_router_management"])) + verdict = await validate_complexity_router_config( + ComplexityRouterConfigValidationRequest(complexity_router_config=config, team_id="team-1"), member + ) + assert verdict.valid is True + # The rehearsed create carries no name, so the same member who may not edit a teammate's + # router still gets the dry run for a router of their own, whatever names the proxy serves. + routed = await preview_auto_router_routing( + AutoRouterRoutingTestRequest.model_validate( + {"prompt": "what is 2+2", "complexity_router_config": config, "team_id": "team-1"} + ), + member, + ) + assert routed.routed_model == "cheap-model" + + def test_every_shadow_eval_sql_constant_speaks_naive_utc(): """The tables store naive UTC wall time (prisma's convention), so SQL-side time must be NOW() AT TIME ZONE 'utc' and python-side params must cast ::timestamp; a bare NOW() or a diff --git a/tests/test_litellm/proxy/management_endpoints/test_common_utils.py b/tests/test_litellm/proxy/management_endpoints/test_common_utils.py index da8fc760787..9c7a6690aa7 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_common_utils.py +++ b/tests/test_litellm/proxy/management_endpoints/test_common_utils.py @@ -812,7 +812,7 @@ class TestIsUserOrgAdminForTeam: class TestTeamMemberHasPermission: def test_requires_caller_to_be_a_team_member(self): from litellm.proxy.management_endpoints.common_utils import ( - _team_member_has_permission, + team_member_has_permission, ) team = LiteLLM_TeamTable( @@ -823,7 +823,7 @@ class TestTeamMemberHasPermission: key = UserAPIKeyAuth( user_id="u1", api_key="sk-x", user_role=LitellmUserRoles.INTERNAL_USER ) - assert _team_member_has_permission(key, team, "/key/generate") is False + assert team_member_has_permission(key, team, "/key/generate") is False class TestUserHasAdminPrivilegesGuard: diff --git a/tests/test_litellm/proxy/management_endpoints/test_model_management_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_model_management_endpoints.py index f490e245082..c4d6e34f19a 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_model_management_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_model_management_endpoints.py @@ -7,8 +7,10 @@ from typing import Dict, Optional from unittest.mock import AsyncMock, MagicMock, patch import pytest +from fastapi import HTTPException from fastapi.testclient import TestClient +import litellm from litellm._uuid import uuid from litellm.proxy._types import ( @@ -23,6 +25,7 @@ from litellm.proxy._types import ( ) from litellm.proxy.common_utils.encrypt_decrypt_utils import encrypt_value_helper from litellm.proxy.management_endpoints.model_management_endpoints import ( + ModelWrite, ModelManagementAuthChecks, _get_team_deployments, _raise_if_rate_limits_required_but_missing, @@ -48,10 +51,12 @@ class MockPrismaClient: team_exists: bool = True, user_admin: bool = True, sibling_deployments: list = None, + team_member_permissions: list = None, ): self.team_exists = team_exists self.user_admin = user_admin self.sibling_deployments = sibling_deployments or [] + self.team_member_permissions = team_member_permissions self.db = self async def find_unique(self, where): @@ -64,6 +69,7 @@ class MockPrismaClient: user_id="test_user", role="admin" if self.user_admin else "user" ) ], + team_member_permissions=self.team_member_permissions, ) return None @@ -177,6 +183,253 @@ class TestModelManagementAuthChecks: ) assert result is True + @staticmethod + def _team_with_member(member_id: str, permissions: list[str] | None) -> LiteLLM_TeamTable: + return LiteLLM_TeamTable( + team_id="test_team", + team_alias="test_team", + members_with_roles=[Member(user_id="someone-else", role="admin"), Member(user_id=member_id, role="user")], + team_member_permissions=permissions, + ) + + @staticmethod + def _named_router(public_name: str) -> Deployment: + return Deployment( + model_name=public_name, + litellm_params=LiteLLM_Params(model="auto_router/complexity_router"), + model_info={"team_id": "test_team"}, + ) + + @staticmethod + def _router_row( + created_by: str | None, model: str = "auto_router/complexity_router", public_name: str = "my-router" + ) -> Deployment: + return Deployment( + model_name="model_name_test_team_abc", + litellm_params=LiteLLM_Params(model=model, complexity_router_default_model="haiku"), + model_info={"team_id": "test_team", "team_public_model_name": public_name}, + created_by=created_by, + ) + + # Every dimension the member arm judges, one row each, on the write's RESULT: the grant, the + # kind before and after, the row's creator, and the public name's effect on team access. + @pytest.mark.parametrize( + "case, permissions, write, allowed", + [ + ("create router", ["/model/auto_router_management"], ModelWrite(None, _router_row.__func__(None)), True), + ("create regular model", ["/model/auto_router_management"], ModelWrite(None, _router_row.__func__(None, "azure/gpt-4o")), False), + ("create without grant", ["/key/generate"], ModelWrite(None, _router_row.__func__(None)), False), + ("create with empty grant list", [], ModelWrite(None, _router_row.__func__(None)), False), + ("create with no grant list", None, ModelWrite(None, _router_row.__func__(None)), False), + ("edit own router", ["/model/auto_router_management"], ModelWrite(_router_row.__func__("test_user"), updateDeployment(litellm_params=updateLiteLLMParams(complexity_router_default_model="sonnet"))), True), + ("delete own router", ["/model/auto_router_management"], ModelWrite(_router_row.__func__("test_user"), None), True), + ("edit a teammate's router", ["/model/auto_router_management"], ModelWrite(_router_row.__func__("someone-else"), updateDeployment(litellm_params=updateLiteLLMParams(complexity_router_default_model="sonnet"))), False), + ("delete a teammate's router", ["/model/auto_router_management"], ModelWrite(_router_row.__func__("someone-else"), None), False), + ("edit a legacy row with no creator", ["/model/auto_router_management"], ModelWrite(_router_row.__func__(None), None), False), + ("turn own router into a regular model", ["/model/auto_router_management"], ModelWrite(_router_row.__func__("test_user"), updateDeployment(litellm_params=updateLiteLLMParams(model="azure/gpt-4o"))), False), + ("turn own regular model into a router", ["/model/auto_router_management"], ModelWrite(_router_row.__func__("test_user", "azure/gpt-4o"), updateDeployment(litellm_params=updateLiteLLMParams(model="auto_router/complexity_router"))), False), + ("create named *", ["/model/auto_router_management"], ModelWrite(None, _named_router.__func__("*")), False), + ("create named all-proxy-models", ["/model/auto_router_management"], ModelWrite(None, _named_router.__func__("all-proxy-models")), False), + ("create named after a served model group", ["/model/auto_router_management"], ModelWrite(None, _named_router.__func__("gpt-4o")), False), + ("rename own router onto a served model group", ["/model/auto_router_management"], ModelWrite(_router_row.__func__("test_user"), updateDeployment(model_name="gpt-4o")), False), + ("create named after a model_group_alias", ["/model/auto_router_management"], ModelWrite(None, _named_router.__func__("smart")), False), + ("create named after a routing group", ["/model/auto_router_management"], ModelWrite(None, _named_router.__func__("grp")), False), + ("create named after an access group", ["/model/auto_router_management"], ModelWrite(None, _named_router.__func__("tier-1")), False), + ("create named after a global model_alias_map alias", ["/model/auto_router_management"], ModelWrite(None, _named_router.__func__("global-alias")), False), + ("create named under a wildcard deployment", ["/model/auto_router_management"], ModelWrite(None, _named_router.__func__("anthropic/claude-sonnet-5")), False), + ("create named after a deployment id", ["/model/auto_router_management"], ModelWrite(None, _named_router.__func__("dep-id-1")), False), + ("create named after a raw litellm_params.model", ["/model/auto_router_management"], ModelWrite(None, _named_router.__func__("openai/gpt-4o")), False), + ("create named after another team's public name", ["/model/auto_router_management"], ModelWrite(None, _named_router.__func__("other-team-router")), False), + # auto_router_config_path is opened on the proxy host at router init; a member configures inline. + ("create a semantic router from a server file path", ["/model/auto_router_management"], ModelWrite(None, Deployment(model_name="file-router", litellm_params=LiteLLM_Params(model="auto_router/custom", auto_router_config_path="/dev/zero", auto_router_default_model="gpt-4o", auto_router_embedding_model="emb"), model_info={"team_id": "test_team"})), False), + ("point own router at a server file path", ["/model/auto_router_management"], ModelWrite(_router_row.__func__("test_user"), updateDeployment(litellm_params=updateLiteLLMParams(auto_router_config_path="/etc/passwd"))), False), + # The dashboard echoes the current public name (and historically the internal key) on + # every save; neither is a rename, so neither is checked against the resolvers. + ("edit own router echoing its public name", ["/model/auto_router_management"], ModelWrite(_router_row.__func__("test_user", public_name="gpt-4o"), updateDeployment(model_name="gpt-4o", litellm_params=updateLiteLLMParams(complexity_router_default_model="sonnet"))), True), + ("edit own router echoing its internal name", ["/model/auto_router_management"], ModelWrite(_router_row.__func__("test_user", public_name="gpt-4o"), updateDeployment(model_name="model_name_test_team_abc")), True), + ("POST /model/update own router echoing its internal name", ["/model/auto_router_management"], ModelWrite(_router_row.__func__("test_user", public_name="gpt-4o"), Deployment(model_name="model_name_test_team_abc", litellm_params=LiteLLM_Params(model="auto_router/complexity_router"), model_info={"team_id": "test_team"})), True), + ("POST /model/update own router renaming onto a served name", ["/model/auto_router_management"], ModelWrite(_router_row.__func__("test_user"), Deployment(model_name="gpt-4o", litellm_params=LiteLLM_Params(model="auto_router/complexity_router"), model_info={"team_id": "test_team"})), False), + # A move re-homes the grant; a member's router stays on the team that granted it. + ("move own router to another team", ["/model/auto_router_management"], ModelWrite(_router_row.__func__("test_user"), updateDeployment(model_info=ModelInfo(team_id="other_team"))), False), + # The team update path renames a public name only when the payload names the team; a rename + # sent without it would land on the internal routing key and skip the team list. + ("rename own router without naming the team", ["/model/auto_router_management"], ModelWrite(_router_row.__func__("test_user"), updateDeployment(model_name="fresh-name")), False), + ("rename own router naming the team", ["/model/auto_router_management"], ModelWrite(_router_row.__func__("test_user"), updateDeployment(model_name="fresh-name", model_info=ModelInfo(team_id="test_team"))), True), + ("edit own router without naming the team, echoing its internal name", ["/model/auto_router_management"], ModelWrite(_router_row.__func__("test_user"), updateDeployment(model_name="model_name_test_team_abc", litellm_params=updateLiteLLMParams(complexity_router_default_model="sonnet"))), True), + # model_info.team_public_model_name is merged straight into the row, bypassing the rename + # channel's collision check and team list bookkeeping, so a member may only echo it. + ("set model_info.team_public_model_name to a served name", ["/model/auto_router_management"], ModelWrite(_router_row.__func__("test_user"), updateDeployment(model_info=ModelInfo(team_public_model_name="gpt-4o"))), False), + ("set model_info.team_public_model_name to a fresh name", ["/model/auto_router_management"], ModelWrite(_router_row.__func__("test_user"), updateDeployment(model_info=ModelInfo(team_public_model_name="brand-new"))), False), + ("POST /model/update setting team_public_model_name to a served name", ["/model/auto_router_management"], ModelWrite(_router_row.__func__("test_user"), Deployment(model_name="model_name_test_team_abc", litellm_params=LiteLLM_Params(model="auto_router/complexity_router"), model_info={"team_id": "test_team", "team_public_model_name": "gpt-4o"})), False), + ("edit own router echoing model_info.team_public_model_name", ["/model/auto_router_management"], ModelWrite(_router_row.__func__("test_user"), updateDeployment(model_info=ModelInfo(team_id="test_team", team_public_model_name="my-router"), litellm_params=updateLiteLLMParams(complexity_router_default_model="sonnet"))), True), + # A dry run rehearses a create with no name; the name guard has nothing to judge, so a + # router the proxy already serves under a colliding name cannot deny the rehearsal. + ("rehearse a create with no name", ["/model/auto_router_management"], ModelWrite(None, updateDeployment(litellm_params=updateLiteLLMParams(model="auto_router/complexity_router"), model_info=ModelInfo(team_id="test_team"))), True), + ("create a semantic router configured inline", ["/model/auto_router_management"], ModelWrite(None, Deployment(model_name="inline-router", litellm_params=LiteLLM_Params(model="auto_router/custom", auto_router_config='{"routes": []}', auto_router_default_model="gpt-4o", auto_router_embedding_model="emb"), model_info={"team_id": "test_team"})), True), + # The stored name became a collision after the router was created: the member still + # edits and deletes what they own, since neither write registers a name. + ("edit own router whose name is now served", ["/model/auto_router_management"], ModelWrite(_router_row.__func__("test_user", public_name="gpt-4o"), updateDeployment(litellm_params=updateLiteLLMParams(complexity_router_default_model="sonnet"))), True), + ("delete own router whose name is now served", ["/model/auto_router_management"], ModelWrite(_router_row.__func__("test_user", public_name="gpt-4o"), None), True), + ], + ) + def test_member_auto_router_arm_judges_the_write_result(self, monkeypatch, case, permissions, write, allowed): + team_obj = self._team_with_member(self.normal_user.user_id, permissions) + # Every identifier the serving path resolves a request by, each in its own slot. + llm_router = Router( + model_list=[ + { + "model_name": "gpt-4o", + "litellm_params": {"model": "openai/gpt-4o", "api_key": "k"}, + "model_info": {"id": "dep-id-1", "access_groups": ["tier-1"]}, + }, + {"model_name": "anthropic/*", "litellm_params": {"model": "anthropic/*", "api_key": "k"}}, + { + "model_name": "model_name_other_team_xyz", + "litellm_params": {"model": "openai/gpt-4o", "api_key": "k"}, + "model_info": {"team_id": "other_team", "team_public_model_name": "other-team-router", "db_model": True}, + }, + ], + model_group_alias={"smart": "gpt-4o"}, + routing_groups=[{"group_name": "grp", "models": ["gpt-4o"], "routing_strategy": "simple-shuffle"}], + ) + monkeypatch.setattr(litellm, "model_alias_map", {"global-alias": "gpt-4o"}) + if allowed: + assert ( + ModelManagementAuthChecks.can_user_make_team_model_call( + team_id="test_team", + user_api_key_dict=self.normal_user, + team_obj=team_obj, + premium_user=True, + member_write=write, + llm_router=llm_router, + ) + is True + ), case + return + with pytest.raises(HTTPException) as exc_info: + ModelManagementAuthChecks.can_user_make_team_model_call( + team_id="test_team", + user_api_key_dict=self.normal_user, + team_obj=team_obj, + premium_user=True, + member_write=write, + llm_router=llm_router, + ) + assert exc_info.value.status_code == 403, case + assert "/model/auto_router_management" in str(exc_info.value.detail) + + def test_auto_router_permission_does_not_reach_a_non_member(self): + team_obj = self._team_with_member("a-real-member", ["/model/auto_router_management"]) + with pytest.raises(HTTPException) as exc_info: + ModelManagementAuthChecks.can_user_make_team_model_call( + team_id="test_team", + user_api_key_dict=self.normal_user, + team_obj=team_obj, + premium_user=True, + member_write=ModelWrite(None, self._router_row(None)), + ) + assert exc_info.value.status_code == 403 + + def test_auto_router_permission_is_still_premium_gated(self): + team_obj = self._team_with_member(self.normal_user.user_id, ["/model/auto_router_management"]) + with pytest.raises(HTTPException) as exc_info: + ModelManagementAuthChecks.can_user_make_team_model_call( + team_id="test_team", + user_api_key_dict=self.normal_user, + team_obj=team_obj, + premium_user=False, + member_write=ModelWrite(None, self._router_row(None)), + ) + assert exc_info.value.status_code == 403 + + # A name can only be cleared against a router; with none to ask, a create is refused. + def test_member_create_fails_closed_without_a_router(self): + team_obj = self._team_with_member(self.normal_user.user_id, ["/model/auto_router_management"]) + with pytest.raises(HTTPException) as exc_info: + ModelManagementAuthChecks.can_user_make_team_model_call( + team_id="test_team", + user_api_key_dict=self.normal_user, + team_obj=team_obj, + premium_user=True, + member_write=ModelWrite(None, self._router_row(None)), + llm_router=None, + ) + assert exc_info.value.status_code == 403 + + # Route RBAC lets view-only roles reach the self-managed model routes; the grant never does. + @pytest.mark.parametrize("role", [LitellmUserRoles.INTERNAL_USER_VIEW_ONLY, LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY]) + def test_view_only_roles_never_get_the_member_grant(self, role): + viewer = UserAPIKeyAuth(user_id="test_user", user_role=role) + team_obj = self._team_with_member(viewer.user_id, ["/model/auto_router_management"]) + with pytest.raises(HTTPException) as exc_info: + ModelManagementAuthChecks.can_user_make_team_model_call( + team_id="test_team", + user_api_key_dict=viewer, + team_obj=team_obj, + premium_user=True, + member_write=ModelWrite(None, self._router_row(None)), + llm_router=Router(model_list=[]), + ) + assert exc_info.value.status_code == 403 + + # A caller that passes no write (the health probe) keeps the team-admin-only verdict. + def test_member_arm_is_off_when_the_caller_passes_no_write(self): + team_obj = self._team_with_member(self.normal_user.user_id, ["/model/auto_router_management"]) + with pytest.raises(HTTPException): + ModelManagementAuthChecks.can_user_make_team_model_call( + team_id="test_team", user_api_key_dict=self.normal_user, team_obj=team_obj, premium_user=True + ) + + # The stored row decides the kind and the creator, and encryption at rest hides neither; the + # caller-writable model_info.created_by is never what the check reads. + @pytest.mark.asyncio + @pytest.mark.parametrize( + "stored_model, encrypted, row_created_by, info_created_by, allowed", + [ + ("auto_router/complexity_router", False, "test_user", None, True), + ("auto_router/complexity_router", True, "test_user", None, True), + ("auto_router/my-semantic", True, "test_user", None, True), + ("auto_router/complexity_router", True, "someone-else", "test_user", False), + ("azure/gpt-4o-mini", False, "test_user", None, False), + ("azure/gpt-4o-mini", True, "test_user", None, False), + ], + ) + async def test_can_user_make_model_call_classifies_the_stored_deployment( + self, monkeypatch, stored_model, encrypted, row_created_by, info_created_by, allowed + ): + monkeypatch.setenv("LITELLM_SALT_KEY", "sk-1234") + model_params = Deployment( + model_name="model_name_test_team_abc", + litellm_params=LiteLLM_Params(model=encrypt_value_helper(stored_model) if encrypted else stored_model), + model_info={"team_id": "test_team", "team_public_model_name": "my-router", "created_by": info_created_by}, + created_by=row_created_by, + ) + prisma_client = MockPrismaClient( + team_exists=True, user_admin=False, team_member_permissions=["/model/auto_router_management"] + ) + write = ModelWrite(stored=model_params, incoming=None) + if allowed: + assert ( + await ModelManagementAuthChecks.can_user_make_model_call( + model_params=model_params, + user_api_key_dict=self.normal_user, + prisma_client=prisma_client, + premium_user=True, + member_write=write, + ) + is True + ) + return + with pytest.raises(HTTPException) as exc_info: + await ModelManagementAuthChecks.can_user_make_model_call( + model_params=model_params, + user_api_key_dict=self.normal_user, + prisma_client=prisma_client, + premium_user=True, + member_write=write, + ) + assert exc_info.value.status_code == 403 + @pytest.mark.asyncio async def test_allow_team_model_action_success(self): """Test successful team model action""" @@ -1226,7 +1479,7 @@ class TestTeamModelSiblingRouting: side_effect=mock_add_model_to_db, ), patch( - "litellm.proxy.management_endpoints.model_management_endpoints.team_model_add", + "litellm.proxy.management_endpoints.model_management_endpoints._register_team_models", mock_team_model_add, ), ): @@ -1373,7 +1626,7 @@ class TestTeamModelUpdate: True, ), patch( - "litellm.proxy.management_endpoints.model_management_endpoints.team_model_add" + "litellm.proxy.management_endpoints.model_management_endpoints._register_team_models" ) as mock_team_model_add, patch( "litellm.proxy.management_endpoints.model_management_endpoints.update_team" @@ -1559,17 +1812,16 @@ class TestTeamModelUpdate: with ( patch( - "litellm.proxy.management_endpoints.model_management_endpoints.team_model_delete" + "litellm.proxy.management_endpoints.model_management_endpoints._unregister_team_models" ) as mock_delete, patch( - "litellm.proxy.management_endpoints.model_management_endpoints.team_model_add" + "litellm.proxy.management_endpoints.model_management_endpoints._register_team_models" ) as mock_add, ): await _update_existing_team_model_assignment( team_id="team_123", public_model_name="new-public-name", db_model=db_model, - user_api_key_dict=user_api_key_dict, prisma_client=prisma_client, # type: ignore ) @@ -1604,17 +1856,16 @@ class TestTeamModelUpdate: with ( patch( - "litellm.proxy.management_endpoints.model_management_endpoints.team_model_delete" + "litellm.proxy.management_endpoints.model_management_endpoints._unregister_team_models" ) as mock_delete, patch( - "litellm.proxy.management_endpoints.model_management_endpoints.team_model_add" + "litellm.proxy.management_endpoints.model_management_endpoints._register_team_models" ) as mock_add, ): await _update_existing_team_model_assignment( team_id="team_123", public_model_name="new-public-name", db_model=db_model, - user_api_key_dict=user_api_key_dict, prisma_client=None, ) @@ -1654,7 +1905,7 @@ class TestTeamModelUpdate: written.update(update_data) return update_data - async def team_add(**_): + async def team_add(*_: object): events.append("team_model_add") with ( @@ -1664,7 +1915,7 @@ class TestTeamModelUpdate: ), patch("litellm.proxy.proxy_server.premium_user", True), # test-quality-ok: team models are premium-gated through a proxy global with no injection seam patch( # test-quality-ok: the team list write is the collaborator whose ordering is asserted - "litellm.proxy.management_endpoints.model_management_endpoints.team_model_add", + "litellm.proxy.management_endpoints.model_management_endpoints._register_team_models", side_effect=team_add, ), ): @@ -1729,17 +1980,16 @@ class TestTeamModelUpdate: with ( patch( - "litellm.proxy.management_endpoints.model_management_endpoints.team_model_delete" + "litellm.proxy.management_endpoints.model_management_endpoints._unregister_team_models" ) as mock_delete, patch( - "litellm.proxy.management_endpoints.model_management_endpoints.team_model_add" + "litellm.proxy.management_endpoints.model_management_endpoints._register_team_models" ) as mock_add, ): await _update_existing_team_model_assignment( team_id="team_123", public_model_name="new-public-name", db_model=db_model, - user_api_key_dict=user_api_key_dict, prisma_client=prisma_client, # type: ignore ) @@ -2055,10 +2305,10 @@ class TestTeamModelUpdate: True, ), patch( - "litellm.proxy.management_endpoints.model_management_endpoints.team_model_add" + "litellm.proxy.management_endpoints.model_management_endpoints._register_team_models" ) as mock_team_model_add, patch( - "litellm.proxy.management_endpoints.model_management_endpoints.team_model_delete" + "litellm.proxy.management_endpoints.model_management_endpoints._unregister_team_models" ) as mock_team_model_delete, ): result = await _update_team_model_in_db( @@ -4962,7 +5212,7 @@ class TestStrategyRouterWriteValidation: yield MagicMock(create=AsyncMock(return_value=created)) events.append("slot-exit") - async def team_model_add(**_: object) -> None: + async def team_model_add(*_: object) -> None: events.append("team_model_add") deployment = Deployment( @@ -4976,7 +5226,7 @@ class TestStrategyRouterWriteValidation: lambda value, new_encryption_key=None: value, ), patch( # test-quality-ok: the team list write is the collaborator whose ordering is asserted - "litellm.proxy.management_endpoints.model_management_endpoints.team_model_add", + "litellm.proxy.management_endpoints.model_management_endpoints._register_team_models", side_effect=team_model_add, ), ): diff --git a/tests/test_litellm/proxy/management_helpers/test_team_member_permission_checks.py b/tests/test_litellm/proxy/management_helpers/test_team_member_permission_checks.py index 36c61eddbb2..9c2c41badd4 100644 --- a/tests/test_litellm/proxy/management_helpers/test_team_member_permission_checks.py +++ b/tests/test_litellm/proxy/management_helpers/test_team_member_permission_checks.py @@ -17,6 +17,21 @@ def _make_team_table(team_member_permissions): return team +def test_auto_router_management_is_grantable_and_leaves_key_permissions_intact(): + """The grant has to be an enum value: a stored permission outside the enum breaks + key-permission resolution for the whole team, and /team/permissions_list only offers + what this helper returns.""" + available = TeamMemberPermissionChecks.get_all_available_team_member_permissions() + assert KeyManagementRoutes.AUTO_ROUTER_MANAGEMENT.value in available + + team = _make_team_table(["/key/generate", KeyManagementRoutes.AUTO_ROUTER_MANAGEMENT.value]) + result = TeamMemberPermissionChecks.get_permissions_for_team_member( + team_member_object=MagicMock(spec=Member), team_table=team + ) + assert KeyManagementRoutes.KEY_GENERATE in result + assert KeyManagementRoutes.AUTO_ROUTER_MANAGEMENT in result + + class TestGetPermissionsForTeamMember: def test_none_permissions_returns_defaults(self): """When team_member_permissions is None, return DEFAULT_TEAM_MEMBER_PERMISSIONS.""" diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/autoRouterRows.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/autoRouterRows.test.ts index 23585f6c110..695491dd4bc 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/autoRouterRows.test.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/autoRouterRows.test.ts @@ -261,6 +261,36 @@ describe("autoRouterRows actor gating", () => { expect(row.canDelete).toBe(true); }); + // Rows are auto-routers by construction, so the member permission reaches them: the + // server admits a granted member's PATCH and DELETE on the team's own routers only. + it("gives a granted member write affordances on the routers they created, on their own team", () => { + const MEMBER = { userRole: "Internal User", userID: "u-member", isViewOnly: false }; + const grantedTeams = [ + { + team_id: "team-1", + members_with_roles: [{ user_id: "u-member", user_email: "m@t", role: "user" }], + team_member_permissions: ["/model/auto_router_management"], + }, + ] as never; + const rowFor = (teamId: string | null, createdBy: string | null) => + toAutoRouterRow( + { + ...complexityDeployment, + model_info: { id: "cid-1", db_model: true, team_id: teamId, created_by: createdBy }, + }, + 0, + MEMBER, + grantedTeams, + ); + expect(rowFor("team-1", "u-member").canEdit).toBe(true); + expect(rowFor("team-1", "u-member").canDelete).toBe(true); + // The server 403s a member's PATCH and DELETE on a teammate's router and on an unattributed row. + expect(rowFor("team-1", "u-teammate").canDelete).toBe(false); + expect(rowFor("team-1", null).canDelete).toBe(false); + expect(rowFor("other-team", "u-member").canDelete).toBe(false); + expect(rowFor(null, "u-member").canDelete).toBe(false); + }); + // A proxy_admin_viewer session reads "Admin" through the masquerade, but PATCH and // DELETE both 403 it, so its rows must not offer the affordances. it("hides write affordances from a view-only admin session", () => { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/autoRouterRows.ts b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/autoRouterRows.ts index c4d7f45b7cc..49a75a6fa48 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/autoRouterRows.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/autoRouterRows.ts @@ -106,7 +106,13 @@ export const toAutoRouterRow = ( const name = deployment.model_name ?? ""; const strategy = autoRouterStrategy(params); const { canEdit, canDelete, editBlockedReason } = autoRouterCapabilities(params, info); - const mayActOnRow = canModifyModel(actor, teams, { teamId: info.team_id, isDbModel: info.db_model === true }); + const origin = { + teamId: info.team_id, + isDbModel: info.db_model === true, + isAutoRouter: true, + createdBy: info.created_by, + }; + const mayActOnRow = canModifyModel(actor, teams, origin); return { id: info.id ?? `${name}-${index}`, diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.test.tsx index 84a05113177..504e6947501 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.test.tsx @@ -30,7 +30,8 @@ vi.mock("@/components/team/TeamInfo", () => ({ const mockUseAuthorized = vi.fn(); vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ default: () => mockUseAuthorized() })); -vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({ useTeams: () => ({ data: [] }) })); +const mockTeams = vi.fn(() => [] as unknown[]); +vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({ useTeams: () => ({ data: mockTeams() }) })); vi.mock("@/app/(dashboard)/hooks/uiSettings/useUISettings", () => ({ useUISettings: () => ({ data: { values: {} } }), })); @@ -152,5 +153,22 @@ describe("ModelsAndEndpointsPage", () => { expect(screen.queryByRole("tab", { name: /Auto-Routers/ })).not.toBeInTheDocument(); }); + + // The one grant that opens model writes to a plain member opens only this tab: Add Model + // stays hidden because POST /model/new still 403s a regular model from them. + it("shows for a member whose team grants auto-router management, without Add Model", () => { + mockUseAuthorized.mockReturnValue(NON_ADMIN); + mockTeams.mockReturnValue([ + { + team_id: "team-1", + members_with_roles: [{ user_id: "u1", user_email: "u@t", role: "user" }], + team_member_permissions: ["/model/auto_router_management"], + }, + ]); + renderPage(); + + expect(screen.getByRole("tab", { name: /Auto-Routers/ })).toBeInTheDocument(); + expect(screen.queryByRole("tab", { name: "Add Model" })).not.toBeInTheDocument(); + }); }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.tsx index 34c9d87004e..bf5b2d5cbc4 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.tsx @@ -7,7 +7,7 @@ import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams"; import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings"; import { all_admin_roles, internalUserRoles } from "@/utils/roles"; -import { canCreateModels } from "@/utils/modelPermissions"; +import { autoRouterCreationScope, canCreateModels } from "@/utils/modelPermissions"; import BetaBadge from "@/components/BetaBadge"; import CostOptimizationFeedbackBanner from "@/components/molecules/cost_optimization_feedback_banner"; import ModelInfoView from "@/components/model_info_view"; @@ -91,21 +91,21 @@ export default function ModelsAndEndpointsPage() { const [lastRefreshed, setLastRefreshed] = useState(""); const isInternalUser = userRole && internalUserRoles.includes(userRole); - const canCreate = canCreateModels( - { userRole, userID, isViewOnly }, - { - teams: teams ?? null, - disabledForInternalUsers: - isInternalUser === true && uiSettings?.values?.disable_model_add_for_internal_users === true, - }, - ); + const modelActor = { userRole, userID, isViewOnly }; + const creationLimits = { + teams: teams ?? null, + disabledForInternalUsers: + isInternalUser === true && uiSettings?.values?.disable_model_add_for_internal_users === true, + }; + const canCreate = canCreateModels(modelActor, creationLimits); + const canCreateAutoRouters = autoRouterCreationScope(modelActor, creationLimits) !== "forbidden"; const isAdmin = all_admin_roles.includes(userRole); const visibleSlugs = useMemo>( () => [ "", ...(canCreate ? (["add"] as const) : []), - ...(isAdmin || canCreate ? (["auto-routers"] as const) : []), + ...(isAdmin || canCreateAutoRouters ? (["auto-routers"] as const) : []), ...(isAdmin ? ([ "llm-credentials", @@ -118,7 +118,7 @@ export default function ModelsAndEndpointsPage() { ] as const) : []), ], - [canCreate, isAdmin], + [canCreate, canCreateAutoRouters, isAdmin], ); const allModelsLabel = isAdmin ? "All Models" : "Your Models"; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AutoRoutersTabPanel.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AutoRoutersTabPanel.test.tsx index 12f0b95bf13..8a9a846d433 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AutoRoutersTabPanel.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AutoRoutersTabPanel.test.tsx @@ -12,8 +12,9 @@ vi.mock("../components/AutoRouters/AutoRoutersPanel", () => ({ })); const mockUseAuthorized = vi.fn(); +const mockTeams = vi.fn(() => [] as unknown[]); vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ default: () => mockUseAuthorized() })); -vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({ useTeams: () => ({ data: [] }) })); +vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({ useTeams: () => ({ data: mockTeams() }) })); vi.mock("@/app/(dashboard)/hooks/uiSettings/useUISettings", () => ({ useUISettings: () => ({ data: { values: {} } }), })); @@ -36,4 +37,19 @@ describe("AutoRoutersTabPanel", () => { render(); expect(lastProps().createScope).toBe("forbidden"); }); + + // This tab, unlike Add Model, also opens to a plain member whose team grants the + // auto-router permission, and the create must then name that team. + it("scopes a granted team member's create to a team", () => { + mockUseAuthorized.mockReturnValue({ ...SESSION, userRole: "Internal User", userId: "u-member" }); + mockTeams.mockReturnValue([ + { + team_id: "team-1", + members_with_roles: [{ user_id: "u-member", user_email: "m@t", role: "user" }], + team_member_permissions: ["/model/auto_router_management"], + }, + ]); + render(); + expect(lastProps().createScope).toBe("team-required"); + }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AutoRoutersTabPanel.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AutoRoutersTabPanel.tsx index 69b442da09b..ce10a756fa6 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AutoRoutersTabPanel.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AutoRoutersTabPanel.tsx @@ -4,14 +4,15 @@ import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams"; import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import { internalUserRoles } from "@/utils/roles"; -import { modelCreationScope } from "@/utils/modelPermissions"; +import { autoRouterCreationScope } from "@/utils/modelPermissions"; import { AutoRoutersPanel } from "../components/AutoRouters/AutoRoutersPanel"; /** * Owns the permission decision for the Auto-Routers tab so the panel stays a renderer. * Creating an auto router is a POST /model/new, the same endpoint Add Model posts to, so it - * takes the same audience rule: a proxy admin, or a team admin who scopes it to a team. + * takes that audience rule plus the one grant specific to routers: a proxy admin, a team + * admin who scopes it to a team, or a member of a team that grants auto-router management. * Viewer roles reach the list without write affordances. */ export default function AutoRoutersTabPanel() { @@ -20,7 +21,7 @@ export default function AutoRoutersTabPanel() { const { data: uiSettings } = useUISettings(); const isInternalUser = userRole != null && internalUserRoles.includes(userRole); - const scope = modelCreationScope( + const scope = autoRouterCreationScope( { userRole, userID, isViewOnly }, { teams: teams ?? null, diff --git a/ui/litellm-dashboard/src/components/key_team_helpers/key_list.tsx b/ui/litellm-dashboard/src/components/key_team_helpers/key_list.tsx index a6cc940c7fd..7b84464507d 100644 --- a/ui/litellm-dashboard/src/components/key_team_helpers/key_list.tsx +++ b/ui/litellm-dashboard/src/components/key_team_helpers/key_list.tsx @@ -22,6 +22,7 @@ export interface Team { keys_count?: number; members_count?: number; members_with_roles: Member[]; + team_member_permissions?: string[] | null; spend: number; access_group_ids?: string[]; access_group_models?: string[]; diff --git a/ui/litellm-dashboard/src/components/model_info_view.tsx b/ui/litellm-dashboard/src/components/model_info_view.tsx index 35afcdb2985..46d286c59bc 100644 --- a/ui/litellm-dashboard/src/components/model_info_view.tsx +++ b/ui/litellm-dashboard/src/components/model_info_view.tsx @@ -169,10 +169,13 @@ export default function ModelInfoView({ // Keep modelData variable name for backwards compatibility const modelData = transformedModelData; - const canEditModel = canModifyModel({ userRole, userID, isViewOnly }, teams ?? null, { + const rowOrigin = { teamId: modelData?.model_info?.team_id, isDbModel: modelData?.model_info?.db_model === true, - }); + isAutoRouter: isAutoRouterDeployment(modelData?.litellm_params), + createdBy: modelData?.model_info?.created_by, + }; + const canEditModel = canModifyModel({ userRole, userID, isViewOnly }, teams ?? null, rowOrigin); const isAdmin = userRole === "Admin"; // Editor-aware on purpose: an adaptive or quality router must not offer Edit Auto Router. const isAutoRouterModel = hasAutoRouterEditor(modelData?.litellm_params); diff --git a/ui/litellm-dashboard/src/components/team/member_permissions.test.tsx b/ui/litellm-dashboard/src/components/team/member_permissions.test.tsx index 652d4f8e685..603cd2cc858 100644 --- a/ui/litellm-dashboard/src/components/team/member_permissions.test.tsx +++ b/ui/litellm-dashboard/src/components/team/member_permissions.test.tsx @@ -95,6 +95,22 @@ describe("MemberPermissions", () => { }); }); + it("renders the auto-router management permission the server now advertises", async () => { + vi.mocked(networking.getTeamPermissionsCall).mockResolvedValue({ + all_available_permissions: ["/key/generate", "/model/auto_router_management"], + team_member_permissions: [], + }); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByText("/model/auto_router_management")).toBeInTheDocument(); + expect( + screen.getByText("Member can create, edit and delete this team's auto-routers (not other models)"), + ).toBeInTheDocument(); + }); + }); + it("should render team daily activity permission with correct method and description", async () => { vi.mocked(networking.getTeamPermissionsCall).mockResolvedValue({ all_available_permissions: ["/key/generate", "/team/daily/activity"], diff --git a/ui/litellm-dashboard/src/components/team/permission_definitions.tsx b/ui/litellm-dashboard/src/components/team/permission_definitions.tsx index 68ca9d8a2cb..b46f9530076 100644 --- a/ui/litellm-dashboard/src/components/team/permission_definitions.tsx +++ b/ui/litellm-dashboard/src/components/team/permission_definitions.tsx @@ -21,6 +21,7 @@ export const PERMISSION_DESCRIPTIONS: Record = { "/key/block": "Member can block a virtual key belonging to this team", "/key/unblock": "Member can unblock a virtual key belonging to this team", "/key/access_group_assignment": "Member can assign access groups to virtual keys for this team", + "/model/auto_router_management": "Member can create, edit and delete this team's auto-routers (not other models)", "/team/daily/activity": "Member can view all team usage data (not just their own)", "/spend/logs": "Member can view spend logs for the entire team (not just their own)", }; diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 6d62ce2b675..621170743e0 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -28358,7 +28358,7 @@ export interface components { * @description Enum for key management routes * @enum {string} */ - KeyManagementRoutes: "/key/generate" | "/key/update" | "/key/delete" | "/key/regenerate" | "/key/service-account/generate" | "/key/{key_id}/regenerate" | "/key/block" | "/key/unblock" | "/key/bulk_update" | "/team/key/bulk_update" | "/key/{key_id}/reset_spend" | "/key/access_group_assignment" | "/key/info" | "/key/health" | "/key/list" | "/key/aliases" | "/team/daily/activity" | "/team/daily/activity/aggregated" | "/spend/logs" | "/spend/logs/v2"; + KeyManagementRoutes: "/key/generate" | "/key/update" | "/key/delete" | "/key/regenerate" | "/key/service-account/generate" | "/key/{key_id}/regenerate" | "/key/block" | "/key/unblock" | "/key/bulk_update" | "/team/key/bulk_update" | "/key/{key_id}/reset_spend" | "/key/access_group_assignment" | "/model/auto_router_management" | "/key/info" | "/key/health" | "/key/list" | "/key/aliases" | "/team/daily/activity" | "/team/daily/activity/aggregated" | "/spend/logs" | "/spend/logs/v2"; /** * KeyManagementSystem * @enum {string} diff --git a/ui/litellm-dashboard/src/utils/modelPermissions.test.ts b/ui/litellm-dashboard/src/utils/modelPermissions.test.ts index afc2ecd210f..3e9bd7454ff 100644 --- a/ui/litellm-dashboard/src/utils/modelPermissions.test.ts +++ b/ui/litellm-dashboard/src/utils/modelPermissions.test.ts @@ -1,10 +1,18 @@ import { describe, expect, it } from "vitest"; import { Team } from "@/components/networking"; -import { canCreateModels, canModifyModel, modelCreationScope } from "./modelPermissions"; +import { autoRouterCreationScope, canCreateModels, canModifyModel, modelCreationScope } from "./modelPermissions"; -const teamWhere = (userId: string, role: string, teamId = "team-1"): Team[] => - [{ team_id: teamId, members_with_roles: [{ user_id: userId, user_email: "t@test.com", role }] }] as unknown as Team[]; +const teamWhere = (userId: string, role: string, teamId = "team-1", permissions?: string[]): Team[] => + [ + { + team_id: teamId, + members_with_roles: [{ user_id: userId, user_email: "t@test.com", role }], + team_member_permissions: permissions, + }, + ] as unknown as Team[]; + +const AUTO_ROUTER_GRANT = ["/key/generate", "/model/auto_router_management"]; const PROXY_ADMIN = { userRole: "Admin", userID: "u-admin", isViewOnly: false }; const TEAM_ADMIN = { userRole: "Internal User", userID: "u-team-admin", isViewOnly: false }; @@ -62,9 +70,81 @@ describe("modelCreationScope", () => { }); }); +// Mirrors the server: /model/new admits a non-admin member for an auto_router/ deployment +// only when their team grants the permission, and the write must still name that team. +describe("autoRouterCreationScope", () => { + it("lets a granted member create, scoped to a team", () => { + expect( + autoRouterCreationScope(MEMBER, { + teams: teamWhere("u-member", "user", "team-1", AUTO_ROUTER_GRANT), + ...noLimits, + }), + ).toBe("team-required"); + }); + + it("still forbids a member whose team grants only key permissions", () => { + expect( + autoRouterCreationScope(MEMBER, { + teams: teamWhere("u-member", "user", "team-1", ["/key/generate"]), + ...noLimits, + }), + ).toBe("forbidden"); + }); + + // The grant is team-scoped: it opens nothing for a user who is not on that team. + it("does not extend another team's grant to a non-member", () => { + expect( + autoRouterCreationScope(MEMBER, { + teams: teamWhere("someone-else", "user", "team-1", AUTO_ROUTER_GRANT), + ...noLimits, + }), + ).toBe("forbidden"); + }); + + it("keeps the internal-user kill switch and the view-only rule ahead of the grant", () => { + const teams = teamWhere("u-member", "user", "team-1", AUTO_ROUTER_GRANT); + expect(autoRouterCreationScope(MEMBER, { teams, disabledForInternalUsers: true })).toBe("forbidden"); + expect(autoRouterCreationScope({ ...MEMBER, isViewOnly: true }, { teams, ...noLimits })).toBe("forbidden"); + }); + + // The Add Model form must never learn about the grant: the server 403s a regular model. + it("never widens modelCreationScope", () => { + expect( + modelCreationScope(MEMBER, { teams: teamWhere("u-member", "user", "team-1", AUTO_ROUTER_GRANT), ...noLimits }), + ).toBe("forbidden"); + }); + + it("agrees with modelCreationScope for everyone the grant does not concern", () => { + expect(autoRouterCreationScope(PROXY_ADMIN, { teams: null, ...noLimits })).toBe("unscoped-ok"); + expect(autoRouterCreationScope(TEAM_ADMIN, { teams: teamWhere("u-team-admin", "admin"), ...noLimits })).toBe( + "team-required", + ); + }); +}); + describe("canModifyModel", () => { const teamRow = { teamId: "team-1", isDbModel: true }; + // The member permission reaches exactly the auto-routers the member created on their own team, + // so the row has to say what it is and who made it; a teammate's router and a regular model on + // the same team stay team-admin only. + it("lets a granted member act on their own auto-router only", () => { + const teams = teamWhere("u-member", "user", "team-1", AUTO_ROUTER_GRANT); + const own = { ...teamRow, isAutoRouter: true, createdBy: "u-member" }; + expect(canModifyModel(MEMBER, teams, own)).toBe(true); + expect(canModifyModel(MEMBER, teams, { ...own, createdBy: "u-teammate" })).toBe(false); + expect(canModifyModel(MEMBER, teams, { ...own, createdBy: null })).toBe(false); + expect(canModifyModel(MEMBER, teams, { ...own, isAutoRouter: false })).toBe(false); + expect(canModifyModel(MEMBER, teams, teamRow)).toBe(false); + }); + + it("refuses a granted member on another team's auto-router and on an unscoped one", () => { + const teams = teamWhere("u-member", "user", "team-1", AUTO_ROUTER_GRANT); + const own = { isDbModel: true, isAutoRouter: true, createdBy: "u-member" }; + expect(canModifyModel(MEMBER, teams, { ...own, teamId: "other-team" })).toBe(false); + expect(canModifyModel(MEMBER, teams, { ...own, teamId: null })).toBe(false); + }); + // config.yaml rows: PATCH /model/{id}/update 404s and POST /model/delete 400s for everyone. it("refuses a config-defined row even to a proxy admin", () => { expect(canModifyModel(PROXY_ADMIN, null, { teamId: "team-1", isDbModel: false })).toBe(false); diff --git a/ui/litellm-dashboard/src/utils/modelPermissions.ts b/ui/litellm-dashboard/src/utils/modelPermissions.ts index 843d9041026..3c90dbe5a90 100644 --- a/ui/litellm-dashboard/src/utils/modelPermissions.ts +++ b/ui/litellm-dashboard/src/utils/modelPermissions.ts @@ -43,6 +43,21 @@ const isTeamAdminOf = (teams: Team[] | null, userID: string, teamId: string): bo return team != null && isUserTeamAdminForSingleTeam(team.members_with_roles, userID); }; +/** The team member permission that opens the team's auto-routers to non-admin members. */ +export const AUTO_ROUTER_MANAGEMENT_PERMISSION = "/model/auto_router_management"; + +const grantsAutoRouterManagement = (team: Team, userID: string): boolean => + team.team_member_permissions?.includes(AUTO_ROUTER_MANAGEMENT_PERMISSION) === true && + team.members_with_roles?.some((member) => member.user_id === userID) === true; + +const isAutoRouterManagerOf = (teams: Team[] | null, userID: string, teamId: string): boolean => { + const team = teams?.find((candidate) => candidate.team_id === teamId); + return team != null && grantsAutoRouterManagement(team, userID); +}; + +const isAutoRouterManagerOfAnyTeam = (teams: Team[] | null, userID: string): boolean => + teams?.some((team) => grantsAutoRouterManagement(team, userID)) === true; + /** * POST /model/new takes a proxy admin unconditionally, or a team admin whose payload names a * team; an unscoped create from anyone else is a 403. A view-only session is 403d by the @@ -69,6 +84,22 @@ export const modelCreationScope = ( return "forbidden"; }; +/** + * The same POST /model/new, for an `auto_router/` deployment: everything modelCreationScope + * admits, plus a non-admin member of a team that grants auto-router management, who must + * scope the router to that team. The server judges the deployment's kind, so this scope + * must never feed the Add Model form. + */ +export const autoRouterCreationScope = (actor: ModelActor, limits: ModelCreationLimits): ModelWriteScope => { + const scope = modelCreationScope(actor, limits); + if (scope !== "forbidden" || actor.isViewOnly || limits.disabledForInternalUsers) { + return scope; + } + return actor.userID != null && isAutoRouterManagerOfAnyTeam(limits.teams, actor.userID) + ? "team-required" + : "forbidden"; +}; + export const canCreateModels = (actor: ModelActor, limits: ModelCreationLimits): boolean => modelCreationScope(actor, limits) !== "forbidden"; @@ -76,13 +107,20 @@ export interface ModelRowOrigin { teamId: string | null | undefined; /** False for config.yaml rows, which update and delete both refuse whoever asks. */ isDbModel: boolean; + /** An `auto_router/` deployment, the only kind the member permission reaches. */ + isAutoRouter?: boolean; + /** + * Who created the row. Irrelevant to admins (see the note on this file), but the member + * permission reaches only the auto-routers the member created themselves. + */ + createdBy?: string | null; } /** May this actor edit or delete this specific deployment? */ export const canModifyModel = ( actor: ModelActor, teams: Team[] | null, - { teamId, isDbModel }: ModelRowOrigin, + { teamId, isDbModel, isAutoRouter = false, createdBy = null }: ModelRowOrigin, ): boolean => { if (actor.isViewOnly || !isDbModel) { return false; @@ -93,5 +131,8 @@ export const canModifyModel = ( if (actor.userID == null || teamId == null) { return false; } - return isTeamAdminOf(teams, actor.userID, teamId); + if (isTeamAdminOf(teams, actor.userID, teamId)) { + return true; + } + return isAutoRouter && createdBy === actor.userID && isAutoRouterManagerOf(teams, actor.userID, teamId); };