feat(teams): member permission to manage their own team-scoped auto-routers

Adds the /model/auto_router_management team member permission. A
non-admin member of a team that grants it may create auto-routers for
that team and update or delete the ones they created, through the model
management endpoints and both /auto_router dry runs. Ordinary models,
and routers a teammate created, stay team-admin and proxy-admin only.

The arm is judged once, in ModelManagementAuthChecks.can_user_make_team_model_call,
on the RESULT of the write: the deployment must be a strategy router
before and after (so neither a router nor a regular model can be
converted through an update), an existing row must carry the member's
own created_by, and the public name may not widen the team's model
access (no wildcard, no all-proxy-models, no name the router already
serves). The team model list writes model management performs after
that check no longer re-run the HTTP handlers' admin-only auth through
a synthetic Request; they call the extracted add_models_to_team /
remove_models_from_team.

Dashboard: the Auto-Routers tab and Add Auto Router open to a granted
member (team required) while Add Model stays hidden; row and detail
edit/delete appear only on the member's own routers; the team Member
Permissions picker lists the new option.
This commit is contained in:
Tin Chi Lo 2026-09-08 17:59:50 -07:00
parent 97f88f5513
commit a69a79449e
24 changed files with 898 additions and 125 deletions

View file

@ -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 = (

View file

@ -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,
)

View file

@ -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,

View file

@ -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,

View file

@ -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(

View file

@ -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",

View file

@ -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,

View file

@ -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

View file

@ -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:

View file

@ -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,
),
):

View file

@ -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."""

View file

@ -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", () => {

View file

@ -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}`,

View file

@ -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();
});
});
});

View file

@ -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<Array<"" | ModelTabSlug>>(
() => [
"",
...(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";

View file

@ -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(<AutoRoutersTabPanel />);
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(<AutoRoutersTabPanel />);
expect(lastProps().createScope).toBe("team-required");
});
});

View file

@ -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,

View file

@ -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[];

View file

@ -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);

View file

@ -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(<MemberPermissions teamId="team-123" accessToken="token-123" canEditTeam={true} />);
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"],

View file

@ -21,6 +21,7 @@ export const PERMISSION_DESCRIPTIONS: Record<string, string> = {
"/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)",
};

View file

@ -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}

View file

@ -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);

View file

@ -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);
};