mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-15 09:12:18 +00:00
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:
parent
97f88f5513
commit
a69a79449e
24 changed files with 898 additions and 125 deletions
|
|
@ -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 = (
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
):
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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)",
|
||||
};
|
||||
|
|
|
|||
2
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
2
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue