mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-26 06:03:19 +00:00
Merge edea3989f4 into 1ef59fab8b
This commit is contained in:
commit
ebce99bd86
70 changed files with 8281 additions and 458 deletions
|
|
@ -7,7 +7,7 @@
|
|||
## This accepts a list of user id's for whom calls will be rejected
|
||||
|
||||
|
||||
from typing import Literal, Optional
|
||||
from typing import Final, Literal, Optional
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
|
|
@ -17,12 +17,14 @@ from litellm._logging import verbose_proxy_logger
|
|||
from litellm.caching.caching import DualCache
|
||||
from litellm.integrations.custom_logger import CustomLogger
|
||||
from litellm.proxy._types import LiteLLM_EndUserTable, UserAPIKeyAuth
|
||||
from litellm.proxy.common_utils.user_api_key_cache import AUTH_OBJECTS_TARGET
|
||||
from litellm.proxy.common_utils.cache_pydantic_utils import CacheCodec
|
||||
from litellm.proxy.common_utils.user_api_key_cache import AUTH_OBJECTS_TARGET, end_user_block_cache_key
|
||||
from litellm.proxy.utils import PrismaClient
|
||||
|
||||
|
||||
class ENTERPRISE_BlockedUserList(CustomLogger):
|
||||
enforces_request_content: bool = True
|
||||
blocked_user_list: list[str] | None
|
||||
# Class variables or attributes
|
||||
def __init__(self, prisma_client: Optional[PrismaClient]):
|
||||
self.prisma_client = prisma_client
|
||||
|
|
@ -58,6 +60,13 @@ class ENTERPRISE_BlockedUserList(CustomLogger):
|
|||
if litellm.set_verbose is True:
|
||||
print(print_statement) # noqa
|
||||
|
||||
def _database(self) -> Optional[PrismaClient]:
|
||||
if self.prisma_client is not None:
|
||||
return self.prisma_client
|
||||
from litellm.proxy.proxy_server import prisma_client
|
||||
|
||||
return prisma_client
|
||||
|
||||
@with_service_target(AUTH_OBJECTS_TARGET)
|
||||
async def async_pre_call_hook(
|
||||
self,
|
||||
|
|
@ -88,14 +97,15 @@ class ENTERPRISE_BlockedUserList(CustomLogger):
|
|||
},
|
||||
)
|
||||
|
||||
cache_key = f"litellm:end_user_id:{user}"
|
||||
end_user_cache_obj: Optional[LiteLLM_EndUserTable] = cache.get_cache( # type: ignore
|
||||
key=cache_key
|
||||
cache_key: Final = end_user_block_cache_key(f"{user}")
|
||||
end_user_cache_obj: Final = CacheCodec.deserialize(
|
||||
cache.get_cache(key=cache_key), model_type=LiteLLM_EndUserTable
|
||||
)
|
||||
if end_user_cache_obj is None and self.prisma_client is not None:
|
||||
database: Final = self._database()
|
||||
if end_user_cache_obj is None and database is not None:
|
||||
# check db
|
||||
end_user_obj = (
|
||||
await self.prisma_client.db.litellm_endusertable.find_unique(
|
||||
await database.db.litellm_endusertable.find_unique(
|
||||
where={"user_id": user}
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
CREATE INDEX CONCURRENTLY IF NOT EXISTS "LiteLLM_EndUserTable_budget_id_idx" ON "LiteLLM_EndUserTable" ("budget_id");
|
||||
|
|
@ -0,0 +1 @@
|
|||
CREATE INDEX CONCURRENTLY IF NOT EXISTS "LiteLLM_VerificationToken_budget_id_idx" ON "LiteLLM_VerificationToken" ("budget_id");
|
||||
|
|
@ -0,0 +1 @@
|
|||
CREATE INDEX CONCURRENTLY IF NOT EXISTS "LiteLLM_VerificationToken_end_user_budget_id_idx" ON "LiteLLM_VerificationToken" USING GIN (("metadata"->'end_user_budget_id') jsonb_path_ops);
|
||||
|
|
@ -563,6 +563,7 @@ model LiteLLM_VerificationToken {
|
|||
|
||||
// SELECT ... FROM "public"."LiteLLM_VerificationToken" WHERE (("public"."LiteLLM_VerificationToken"."expires" IS NULL OR "public"."LiteLLM_VerificationToken"."expires" > $1) AND "public"."LiteLLM_VerificationToken"."budget_reset_at" < $2) OFFSET $3
|
||||
@@index([budget_reset_at, expires])
|
||||
@@index([budget_id])
|
||||
}
|
||||
|
||||
model LiteLLM_JWTKeyMapping {
|
||||
|
|
@ -678,6 +679,8 @@ model LiteLLM_EndUserTable {
|
|||
litellm_budget_table LiteLLM_BudgetTable? @relation(fields: [budget_id], references: [budget_id])
|
||||
object_permission LiteLLM_ObjectPermissionTable? @relation(fields: [object_permission_id], references: [object_permission_id])
|
||||
blocked Boolean @default(false)
|
||||
|
||||
@@index([budget_id])
|
||||
}
|
||||
|
||||
// Budget and shared spend for a model access group. The groups themselves are not rows anywhere:
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ from collections.abc import Awaitable, Callable, Mapping, Sequence
|
|||
from collections.abc import Set as AbstractSet
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from types import MappingProxyType
|
||||
from types import EllipsisType, MappingProxyType
|
||||
from typing import TYPE_CHECKING, Final, Literal, cast
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
|
@ -464,6 +464,7 @@ def _admission_failure_fallback(
|
|||
class MCPServerAccess:
|
||||
server_ids: tuple[str, ...]
|
||||
scope: Literal["unscoped", "scoped", "unresolved"] = "unscoped"
|
||||
end_user_denies_mcp: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
|
|
@ -1830,7 +1831,21 @@ class MCPRequestHandler:
|
|||
Returns:
|
||||
List[str]: List of allowed MCP servers by server id
|
||||
"""
|
||||
from litellm.proxy.proxy_server import general_settings
|
||||
from litellm.proxy.proxy_server import general_settings, prisma_client
|
||||
|
||||
try:
|
||||
end_user_permission: Final = (
|
||||
await MCPRequestHandler._get_end_user_object_permission(user_api_key_auth, prisma_client)
|
||||
if user_api_key_auth and user_api_key_auth.end_user_id and prisma_client is not None
|
||||
else None
|
||||
)
|
||||
except UnloadableEntitlementError as error:
|
||||
verbose_logger.warning("Denying MCP access, customer entitlement unreadable: %s", error)
|
||||
return MCPServerAccess(server_ids=(), scope="scoped", end_user_denies_mcp=True)
|
||||
if end_user_permission is not None and SpecialMCPServerNames.no_mcp_servers.value in (
|
||||
end_user_permission.mcp_servers or []
|
||||
):
|
||||
return MCPServerAccess(server_ids=(), scope="scoped", end_user_denies_mcp=True)
|
||||
|
||||
if managed_agent_policy(user_api_key_auth) is not None:
|
||||
from litellm.proxy._experimental.mcp_server.auth.managed_agent_access import managed_agent_servers
|
||||
|
|
@ -1911,7 +1926,7 @@ class MCPRequestHandler:
|
|||
#########################################################
|
||||
if user_api_key_auth and user_api_key_auth.end_user_id:
|
||||
allowed_mcp_servers_for_end_user: Final = await MCPRequestHandler._get_allowed_mcp_servers_for_end_user(
|
||||
user_api_key_auth
|
||||
user_api_key_auth, object_permission=end_user_permission
|
||||
)
|
||||
|
||||
# If end_user has explicit MCP server permissions, apply intersection
|
||||
|
|
@ -1925,11 +1940,10 @@ class MCPRequestHandler:
|
|||
|
||||
# Always apply intersection: key/team AND end_user
|
||||
# This ensures end_user can only access servers that both they AND their key/team are authorized for
|
||||
filtered_servers: Final = []
|
||||
for _mcp_server in allowed_mcp_servers:
|
||||
if _mcp_server in allowed_mcp_servers_for_end_user:
|
||||
filtered_servers.append(_mcp_server)
|
||||
allowed_mcp_servers = filtered_servers
|
||||
end_user_servers: Final = frozenset(allowed_mcp_servers_for_end_user)
|
||||
allowed_mcp_servers = [
|
||||
server_id for server_id in allowed_mcp_servers if server_id in end_user_servers
|
||||
]
|
||||
verbose_logger.debug(
|
||||
"Applied end_user intersection filter. Final allowed servers: %s", allowed_mcp_servers
|
||||
)
|
||||
|
|
@ -3428,6 +3442,8 @@ class MCPRequestHandler:
|
|||
@staticmethod
|
||||
async def _get_allowed_mcp_servers_for_end_user(
|
||||
user_api_key_auth: UserAPIKeyAuth | None = None,
|
||||
*,
|
||||
object_permission: LiteLLM_ObjectPermissionTable | None | EllipsisType = ...,
|
||||
) -> list[str]:
|
||||
"""
|
||||
Get allowed MCP servers for an end user.
|
||||
|
|
@ -3445,9 +3461,19 @@ class MCPRequestHandler:
|
|||
verbose_logger.debug("prisma_client is None")
|
||||
return []
|
||||
|
||||
object_permission = await MCPRequestHandler._get_end_user_object_permission(user_api_key_auth, prisma_client)
|
||||
if object_permission is None:
|
||||
permission: Final = (
|
||||
await MCPRequestHandler._get_end_user_object_permission(user_api_key_auth, prisma_client)
|
||||
if object_permission is ...
|
||||
else object_permission
|
||||
)
|
||||
if permission is None:
|
||||
return []
|
||||
if SpecialMCPServerNames.no_mcp_servers.value in (permission.mcp_servers or []):
|
||||
return [SpecialMCPServerNames.no_mcp_servers.value]
|
||||
|
||||
toolset_grants: Final = await MCPRequestHandler._toolset_tool_permissions(
|
||||
permission, requires_fresh_policy=user_api_key_auth.requires_fresh_policy
|
||||
)
|
||||
|
||||
try:
|
||||
# Permission entries may be server_ids OR names/aliases — expand to ids.
|
||||
|
|
@ -3455,25 +3481,28 @@ class MCPRequestHandler:
|
|||
global_mcp_server_manager,
|
||||
)
|
||||
|
||||
direct_mcp_servers = global_mcp_server_manager.expand_permission_list(object_permission.mcp_servers or [])
|
||||
direct_mcp_servers: Final = global_mcp_server_manager.expand_permission_list(permission.mcp_servers or [])
|
||||
|
||||
# Get MCP servers from access groups
|
||||
access_group_servers: Final = await MCPRequestHandler.get_mcp_servers_from_access_groups(
|
||||
object_permission.mcp_access_groups or [],
|
||||
permission.mcp_access_groups or [],
|
||||
requires_fresh_policy=bool(user_api_key_auth and user_api_key_auth.requires_fresh_policy),
|
||||
)
|
||||
|
||||
# servers referenced in tool permissions should also be accessible
|
||||
tool_perm_servers: Final = list(
|
||||
global_mcp_server_manager.expand_tool_permissions(object_permission.mcp_tool_permissions).keys()
|
||||
global_mcp_server_manager.expand_tool_permissions(permission.mcp_tool_permissions).keys()
|
||||
)
|
||||
|
||||
# Combine all lists
|
||||
all_servers: Final = direct_mcp_servers + access_group_servers + tool_perm_servers
|
||||
return list(MCPRequestHandler._server_grants_with_group_scope(set(all_servers), object_permission))
|
||||
all_servers: Final = frozenset(
|
||||
(*direct_mcp_servers, *access_group_servers, *tool_perm_servers, *toolset_grants)
|
||||
)
|
||||
return list(MCPRequestHandler._server_grants_with_group_scope(all_servers, permission))
|
||||
except Exception as e:
|
||||
verbose_logger.warning("Failed to get allowed MCP servers for end_user: %s", e)
|
||||
return []
|
||||
raise UnloadableEntitlementError(
|
||||
f"end user {user_api_key_auth.end_user_id!r} has an object permission "
|
||||
"whose MCP servers could not be read"
|
||||
) from e
|
||||
|
||||
@staticmethod
|
||||
async def _get_user_object_permission(
|
||||
|
|
|
|||
|
|
@ -3693,6 +3693,14 @@ class MCPServerManager:
|
|||
)
|
||||
|
||||
try:
|
||||
customer_access: Final = (
|
||||
access or await MCPRequestHandler.get_mcp_server_access(user_api_key_auth)
|
||||
if user_api_key_auth and user_api_key_auth.end_user_id
|
||||
else None
|
||||
)
|
||||
if customer_access is not None and customer_access.end_user_denies_mcp:
|
||||
return []
|
||||
|
||||
# Admin view with no explicit object permission and no entitlement ceiling resolves the
|
||||
# whole registry, for keys AND admitted session subjects alike (one predicate owns the
|
||||
# question). Seeded into the union rather than returned early so the session resource
|
||||
|
|
@ -3701,7 +3709,7 @@ class MCPServerManager:
|
|||
resolved_access: Final = (
|
||||
MCPServerAccess(server_ids=())
|
||||
if admin_unscoped
|
||||
else access or await MCPRequestHandler.get_mcp_server_access(user_api_key_auth)
|
||||
else customer_access or access or await MCPRequestHandler.get_mcp_server_access(user_api_key_auth)
|
||||
)
|
||||
resolved_server_ids: Final = (
|
||||
set(self.get_registry().keys()) if admin_unscoped else set(resolved_access.server_ids)
|
||||
|
|
|
|||
|
|
@ -1157,7 +1157,7 @@ class ProviderInfo(LiteLLMPydanticObjectBase):
|
|||
|
||||
|
||||
class BlockUsers(LiteLLMPydanticObjectBase):
|
||||
user_ids: list[str] # required
|
||||
user_ids: list[str] = Field(min_length=1, max_length=1000)
|
||||
|
||||
|
||||
class ModelParams(LiteLLMPydanticObjectBase):
|
||||
|
|
@ -1846,6 +1846,20 @@ class UpdateCustomerRequest(LiteLLMPydanticObjectBase):
|
|||
models: list[str] | None = None
|
||||
object_permission: LiteLLM_ObjectPermissionBase | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_budget_update(self) -> "UpdateCustomerRequest":
|
||||
if {"budget_id", "max_budget"}.issubset(self.model_fields_set):
|
||||
raise ValueError("Set either 'max_budget' or 'budget_id', not both.")
|
||||
return self
|
||||
|
||||
|
||||
class BulkUpdateCustomerRequest(LiteLLMPydanticObjectBase):
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
customers: tuple[UpdateCustomerRequest, ...] = Field(
|
||||
min_length=1, max_length=500, description="Customer updates processed independently in request order"
|
||||
)
|
||||
|
||||
|
||||
class DeleteCustomerRequest(LiteLLMPydanticObjectBase):
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -162,7 +162,7 @@ async def _permission_write(
|
|||
if existing_id is None:
|
||||
created: Final[AgentPermissionWrite] = {"create": prepared.record}
|
||||
return created
|
||||
updated: Final[AgentPermissionWrite] = {"update": prepared.record}
|
||||
updated: Final[AgentPermissionWrite] = {"update": prepared.update_record}
|
||||
return updated
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -33,6 +33,8 @@ _PROXY_ADMIN_VIEW_ONLY_BLOCKED_ROUTES: Final = frozenset(
|
|||
"/user/delete",
|
||||
"/management/v1/users/bulk_delete",
|
||||
"/user/bulk_update",
|
||||
"/customer/bulk_update",
|
||||
"/end_user/bulk_update",
|
||||
# team
|
||||
"/team/new",
|
||||
"/management/v1/teams/{team_id}/members/bulk_delete",
|
||||
|
|
@ -934,6 +936,8 @@ class RouteChecks:
|
|||
"/user/delete",
|
||||
"/management/v1/users/bulk_delete",
|
||||
"/user/bulk_update",
|
||||
"/customer/bulk_update",
|
||||
"/end_user/bulk_update",
|
||||
"/team/new",
|
||||
"/management/v1/teams/{team_id}/members/bulk_delete",
|
||||
"/management/v1/teams/{team_id}/members/bulk_update",
|
||||
|
|
|
|||
|
|
@ -372,6 +372,10 @@ def end_user_cache_key(end_user_id: str) -> str:
|
|||
return f"end_user_id:{end_user_id}"
|
||||
|
||||
|
||||
def end_user_block_cache_key(end_user_id: str) -> str:
|
||||
return f"litellm:end_user_id:{end_user_id}"
|
||||
|
||||
|
||||
def end_user_restricted_registry_cache_key() -> str:
|
||||
"""Cache key for the set of end-user ids whose row carries a restriction auth enforces."""
|
||||
return "end_user_restricted_registry"
|
||||
|
|
|
|||
|
|
@ -6,21 +6,24 @@ All /customer management endpoints
|
|||
/customer/new
|
||||
/customer/info
|
||||
/customer/update
|
||||
/customer/bulk_update
|
||||
/customer/delete
|
||||
"""
|
||||
|
||||
#### END-USER/CUSTOMER MANAGEMENT ####
|
||||
from collections.abc import Mapping, Sequence
|
||||
from collections.abc import Set as AbstractSet
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
from types import MappingProxyType
|
||||
from typing import TYPE_CHECKING, Final, NamedTuple, Protocol, TypeVar, overload
|
||||
from typing import TYPE_CHECKING, Annotated, Final, NamedTuple, Protocol, TypeVar, overload
|
||||
|
||||
import fastapi
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from pydantic import BaseModel, TypeAdapter
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from prisma import Prisma
|
||||
from prisma.models import LiteLLM_BudgetTable as PrismaBudgetRow
|
||||
from prisma.models import LiteLLM_EndUserTable as PrismaEndUserRow
|
||||
|
||||
|
|
@ -32,6 +35,7 @@ from litellm.litellm_core_utils.duration_parser import duration_in_seconds
|
|||
from litellm.proxy._types import *
|
||||
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
||||
from litellm.proxy.common_utils.user_api_key_cache import (
|
||||
end_user_block_cache_key,
|
||||
end_user_cache_key,
|
||||
end_user_restricted_registry_cache_key,
|
||||
)
|
||||
|
|
@ -39,32 +43,58 @@ from litellm.proxy.management_endpoints.common_daily_activity import get_daily_a
|
|||
from litellm.proxy.management_endpoints.common_utils import validate_budget_duration
|
||||
from litellm.proxy.management_helpers.object_permission_utils import ( # noqa: F401 # legacy module exports
|
||||
_set_object_permission, # pyright: ignore[reportPrivateUsage,reportUnusedImport] # backwards-compatible package export
|
||||
handle_update_object_permission_common,
|
||||
prepare_object_permission_upsert,
|
||||
set_object_permission,
|
||||
)
|
||||
from litellm.proxy.utils import handle_exception_on_proxy
|
||||
from litellm.repositories.budget_repository import BudgetRepository
|
||||
from litellm.repositories.chunked_in import find_many_in
|
||||
from litellm.repositories.chunked_in import find_many_in, update_many_in
|
||||
from litellm.repositories.object_permission_repository import ObjectPermissionRepository
|
||||
from litellm.repositories.table_repositories import EndUserRepository
|
||||
from litellm.types.proxy.management_endpoints.common_daily_activity import (
|
||||
SpendAnalyticsPaginatedResponse,
|
||||
)
|
||||
from litellm.types.proxy.management_endpoints.customer_endpoints import (
|
||||
BlockUsersResponse,
|
||||
BulkUpdateCustomerResponse,
|
||||
CustomerResponse,
|
||||
CustomerUpdateResult,
|
||||
DeleteCustomersResponse,
|
||||
UnblockUsersResponse,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _TransactionClient:
|
||||
db: "Prisma"
|
||||
|
||||
|
||||
_RowT_co: Final = TypeVar("_RowT_co", covariant=True)
|
||||
_STR_OBJECT_DICT: Final = TypeAdapter(dict[str, object])
|
||||
_CLEARABLE_LIST_FIELDS: Final = frozenset({"models"})
|
||||
_CUSTOMER_BUDGET_LOCK_SQL: Final = 'SELECT "budget_id" FROM "LiteLLM_BudgetTable" WHERE "budget_id" = $1 FOR UPDATE'
|
||||
_CUSTOMER_SHARED_BUDGET_SQL: Final = """
|
||||
SELECT "budget_id" FROM "LiteLLM_BudgetTable" WHERE "budget_id" = $1 AND EXISTS (
|
||||
SELECT 1 FROM "LiteLLM_EndUserTable" WHERE "budget_id" = $1 AND "user_id" <> $2
|
||||
UNION ALL SELECT 1 FROM "LiteLLM_VerificationToken" WHERE "budget_id" = $1
|
||||
OR (("metadata"->'end_user_budget_id') @> to_jsonb($1::text)
|
||||
AND jsonb_typeof("metadata"->'end_user_budget_id') = 'string')
|
||||
UNION ALL SELECT 1 FROM "LiteLLM_OrganizationTable" WHERE "budget_id" = $1
|
||||
UNION ALL SELECT 1 FROM "LiteLLM_ProjectTable" WHERE "budget_id" = $1
|
||||
UNION ALL SELECT 1 FROM "LiteLLM_TagTable" WHERE "budget_id" = $1
|
||||
UNION ALL SELECT 1 FROM "LiteLLM_ModelAccessGroupBudgetTable" WHERE "budget_id" = $1
|
||||
UNION ALL SELECT 1 FROM "LiteLLM_TeamMembership" WHERE "budget_id" = $1
|
||||
UNION ALL SELECT 1 FROM "LiteLLM_OrganizationMembership" WHERE "budget_id" = $1
|
||||
UNION ALL SELECT 1 FROM "LiteLLM_TeamTable" WHERE "metadata"->>'team_member_budget_id' = $1
|
||||
)
|
||||
"""
|
||||
_CUSTOMER_UPDATE_LOCK_SQL: Final = 'SELECT "user_id" FROM "LiteLLM_EndUserTable" WHERE "user_id" = $1 FOR UPDATE'
|
||||
|
||||
|
||||
def _should_update_field(field: str, value: object, sent_fields: AbstractSet[str]) -> bool:
|
||||
if value is None:
|
||||
return False
|
||||
if field in sent_fields and (isinstance(value, bool) or field in _CLEARABLE_LIST_FIELDS):
|
||||
if field in sent_fields and (isinstance(value, (int, float)) or field in _CLEARABLE_LIST_FIELDS):
|
||||
return True
|
||||
if isinstance(value, (list, dict)) and not value:
|
||||
return False
|
||||
|
|
@ -105,6 +135,8 @@ if TYPE_CHECKING:
|
|||
data: Mapping[str, Mapping[str, object]],
|
||||
) -> _RowT_co: ...
|
||||
|
||||
async def update_many(self, where: Mapping[str, object], data: Mapping[str, object]) -> int: ...
|
||||
|
||||
async def delete_many(self, where: Mapping[str, object]) -> int: ...
|
||||
|
||||
|
||||
|
|
@ -135,7 +167,11 @@ async def _evict_end_user_cache_keys(cache_keys: Sequence[str]) -> None:
|
|||
|
||||
def _end_user_cache_keys(user_ids: Sequence[str]) -> tuple[str, ...]:
|
||||
"""The per-id entries plus the registry, which any restriction change can move ids in or out of."""
|
||||
return (*(end_user_cache_key(user_id) for user_id in user_ids), end_user_restricted_registry_cache_key())
|
||||
return (
|
||||
*(end_user_cache_key(user_id) for user_id in user_ids),
|
||||
end_user_restricted_registry_cache_key(),
|
||||
*(end_user_block_cache_key(user_id) for user_id in user_ids),
|
||||
)
|
||||
|
||||
|
||||
def _to_customer_response(record: BaseModel) -> CustomerResponse:
|
||||
|
|
@ -148,6 +184,34 @@ def _to_customer_response(record: BaseModel) -> CustomerResponse:
|
|||
return CustomerResponse.model_validate(record.model_dump())
|
||||
|
||||
|
||||
class _BlockedUserHook(Protocol):
|
||||
@property
|
||||
def blocked_user_list(self) -> Sequence[str] | None: ...
|
||||
|
||||
|
||||
def _configured_blocked_user_hook() -> _BlockedUserHook:
|
||||
try:
|
||||
from enterprise.enterprise_hooks.blocked_user_list import ENTERPRISE_BlockedUserList
|
||||
except ImportError:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={
|
||||
"error": "Blocked user check was never set. This call has no effect."
|
||||
+ CommonProxyErrors.missing_enterprise_package_docker.value
|
||||
},
|
||||
)
|
||||
configured_hook: Final = next(
|
||||
(callback for callback in litellm.callbacks if isinstance(callback, ENTERPRISE_BlockedUserList)), None
|
||||
)
|
||||
if configured_hook is None:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={"error": "Blocked user check was never set. This call has no effect."},
|
||||
)
|
||||
|
||||
return configured_hook
|
||||
|
||||
|
||||
@router.post(
|
||||
"/end_user/block",
|
||||
tags=["Customer Management"],
|
||||
|
|
@ -160,7 +224,7 @@ def _to_customer_response(record: BaseModel) -> CustomerResponse:
|
|||
dependencies=[Depends(user_api_key_auth)],
|
||||
response_model=BlockUsersResponse,
|
||||
)
|
||||
async def block_user(data: BlockUsers):
|
||||
async def block_user(data: BlockUsers) -> BlockUsersResponse:
|
||||
"""
|
||||
[BETA] Reject calls with this end-user id
|
||||
|
||||
|
|
@ -179,26 +243,28 @@ async def block_user(data: BlockUsers):
|
|||
"""
|
||||
from litellm.proxy.proxy_server import prisma_client
|
||||
|
||||
_configured_blocked_user_hook()
|
||||
try:
|
||||
records: Final = []
|
||||
if prisma_client is not None:
|
||||
for id in data.user_ids:
|
||||
record = await _typed_table(EndUserRepository(prisma_client)).upsert(
|
||||
where={"user_id": id},
|
||||
data={
|
||||
"create": {"user_id": id, "blocked": True},
|
||||
"update": {"blocked": True},
|
||||
},
|
||||
)
|
||||
records.append(record)
|
||||
await _evict_end_user_cache_keys(_end_user_cache_keys(data.user_ids))
|
||||
else:
|
||||
if prisma_client is None:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail={"error": "Postgres DB Not connected"},
|
||||
)
|
||||
|
||||
return {"blocked_users": records}
|
||||
table: Final = _typed_table(EndUserRepository(prisma_client))
|
||||
records: Final = [
|
||||
_to_customer_response(
|
||||
await table.upsert(
|
||||
where={"user_id": user_id},
|
||||
data={
|
||||
"create": {"user_id": user_id, "blocked": True},
|
||||
"update": {"blocked": True},
|
||||
},
|
||||
)
|
||||
)
|
||||
for user_id in data.user_ids
|
||||
]
|
||||
await _evict_end_user_cache_keys(_end_user_cache_keys(data.user_ids))
|
||||
return BlockUsersResponse.model_validate({"blocked_users": records})
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.error("An error occurred - %s", e)
|
||||
raise HTTPException(status_code=500, detail={"error": str(e)})
|
||||
|
|
@ -216,7 +282,7 @@ async def block_user(data: BlockUsers):
|
|||
dependencies=[Depends(user_api_key_auth)],
|
||||
response_model=UnblockUsersResponse,
|
||||
)
|
||||
async def unblock_user(data: BlockUsers):
|
||||
async def unblock_user(data: BlockUsers) -> UnblockUsersResponse:
|
||||
"""
|
||||
[BETA] Unblock calls with this user id
|
||||
|
||||
|
|
@ -229,38 +295,35 @@ async def unblock_user(data: BlockUsers):
|
|||
}'
|
||||
```
|
||||
"""
|
||||
try:
|
||||
from enterprise.enterprise_hooks.blocked_user_list import (
|
||||
ENTERPRISE_BlockedUserList,
|
||||
)
|
||||
except ImportError:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={
|
||||
"error": "Blocked user check was never set. This call has no effect."
|
||||
+ CommonProxyErrors.missing_enterprise_package_docker.value
|
||||
},
|
||||
)
|
||||
from litellm.proxy.proxy_server import prisma_client
|
||||
|
||||
if (
|
||||
not any(isinstance(x, ENTERPRISE_BlockedUserList) for x in litellm.callbacks)
|
||||
or litellm.blocked_user_list is None
|
||||
configured_hook: Final = _configured_blocked_user_hook()
|
||||
|
||||
requested_ids: Final = frozenset(data.user_ids)
|
||||
if isinstance(litellm.blocked_user_list, str) and requested_ids.intersection(
|
||||
configured_hook.blocked_user_list or ()
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={"error": "Blocked user check was never set. This call has no effect."},
|
||||
detail={
|
||||
"error": "These customers are blocked by a file. Update the file and restart the proxy to unblock them."
|
||||
},
|
||||
)
|
||||
|
||||
if prisma_client is not None:
|
||||
await update_many_in(
|
||||
_typed_table(EndUserRepository(prisma_client)),
|
||||
"user_id",
|
||||
data.user_ids,
|
||||
data={"blocked": False},
|
||||
atomicity="per_chunk_ok",
|
||||
)
|
||||
remaining_legacy_ids: Final = tuple(
|
||||
user_id for user_id in configured_hook.blocked_user_list or () if user_id not in requested_ids
|
||||
)
|
||||
if isinstance(litellm.blocked_user_list, list):
|
||||
for id in data.user_ids:
|
||||
litellm.blocked_user_list.remove(id)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail={"error": "`blocked_user_list` must be set as a list. Filepaths can't be updated."},
|
||||
)
|
||||
|
||||
return {"blocked_users": litellm.blocked_user_list}
|
||||
litellm.blocked_user_list[:] = remaining_legacy_ids
|
||||
await _evict_end_user_cache_keys(_end_user_cache_keys(data.user_ids))
|
||||
return UnblockUsersResponse(blocked_users=list(remaining_legacy_ids))
|
||||
|
||||
|
||||
def new_budget_request(data: NewCustomerRequest) -> BudgetNewRequest | None:
|
||||
|
|
@ -289,34 +352,48 @@ def new_budget_request(data: NewCustomerRequest) -> BudgetNewRequest | None:
|
|||
return None
|
||||
|
||||
|
||||
async def _handle_customer_object_permission_update(
|
||||
non_default_values: dict[str, object],
|
||||
end_user_table_data_typed: LiteLLM_EndUserTable | None,
|
||||
update_end_user_table_data: dict[str, object],
|
||||
prisma_client: "PrismaClient",
|
||||
) -> None:
|
||||
"""
|
||||
Handle object permission updates for customer endpoints.
|
||||
|
||||
Updates the update_end_user_table_data dict in place with the new object_permission_id.
|
||||
|
||||
Args:
|
||||
non_default_values: Dictionary containing the update values including object_permission
|
||||
end_user_table_data_typed: Existing end user table data
|
||||
update_end_user_table_data: Dictionary to update with new object_permission_id
|
||||
prisma_client: Prisma database client
|
||||
"""
|
||||
if "object_permission" in non_default_values:
|
||||
existing_object_permission_id: Final = (
|
||||
end_user_table_data_typed.object_permission_id if end_user_table_data_typed is not None else None
|
||||
async def _customer_budget_update_error(
|
||||
tx: _TransactionClient,
|
||||
budget_id: str,
|
||||
user_id: str,
|
||||
) -> ProxyException | None:
|
||||
budget_locked: Final = bool(await tx.db.query_raw(_CUSTOMER_BUDGET_LOCK_SQL, budget_id))
|
||||
if not budget_locked:
|
||||
return ProxyException(message="Budget does not exist", type="not_found", code=404, param="budget_id")
|
||||
shared: Final = budget_id == litellm.max_end_user_budget_id or bool(
|
||||
await tx.db.query_raw(_CUSTOMER_SHARED_BUDGET_SQL, budget_id, user_id)
|
||||
)
|
||||
if shared:
|
||||
return ProxyException(
|
||||
message=(
|
||||
"Cannot update max_budget for a shared budget. Update the budget directly or assign a different budget."
|
||||
),
|
||||
type="bad_request",
|
||||
code=400,
|
||||
param="max_budget",
|
||||
)
|
||||
object_permission_id: Final = await handle_update_object_permission_common(
|
||||
data_json=non_default_values,
|
||||
existing_object_permission_id=existing_object_permission_id,
|
||||
prisma_client=prisma_client,
|
||||
return None
|
||||
|
||||
|
||||
async def _write_customer_budget_update(
|
||||
tx: _TransactionClient,
|
||||
budget_data: Mapping[str, object],
|
||||
existing_budget: LiteLLM_BudgetTable | None,
|
||||
updated_by: str,
|
||||
) -> str | None:
|
||||
if not budget_data:
|
||||
return None
|
||||
if existing_budget is not None:
|
||||
await _typed_table(BudgetRepository(tx)).update(
|
||||
where={"budget_id": existing_budget.budget_id},
|
||||
data=budget_data,
|
||||
)
|
||||
if object_permission_id is not None:
|
||||
update_end_user_table_data["object_permission_id"] = object_permission_id
|
||||
return None
|
||||
budget: Final = await _typed_table(BudgetRepository(tx)).create(
|
||||
data={**budget_data, "created_by": updated_by, "updated_by": updated_by},
|
||||
include={"end_users": True},
|
||||
)
|
||||
return budget.budget_id
|
||||
|
||||
|
||||
@router.post(
|
||||
|
|
@ -593,6 +670,108 @@ async def end_user_info(
|
|||
raise handle_exception_on_proxy(e)
|
||||
|
||||
|
||||
async def _update_single_customer(
|
||||
data: UpdateCustomerRequest,
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
) -> CustomerResponse | ProxyException:
|
||||
from litellm.proxy.proxy_server import litellm_proxy_admin_name, prisma_client
|
||||
|
||||
try:
|
||||
data_json: Final = _STR_OBJECT_DICT.validate_python(data.json())
|
||||
if prisma_client is None:
|
||||
raise Exception("Not connected to DB!")
|
||||
|
||||
sent_fields: Final = data.fields_set()
|
||||
non_default_values: Final[dict[str, object]] = {
|
||||
k: v for k, v in data_json.items() if _should_update_field(k, v, sent_fields)
|
||||
}
|
||||
|
||||
budget_table_data: Final = {
|
||||
field: value
|
||||
for field, value in non_default_values.items()
|
||||
if field != "budget_id" and field in LiteLLM_BudgetTable.model_fields
|
||||
}
|
||||
customer_table_data: Final = {
|
||||
field: value
|
||||
for field, value in non_default_values.items()
|
||||
if field == "budget_id"
|
||||
or (
|
||||
field in LiteLLM_EndUserTable.model_fields
|
||||
and field not in LiteLLM_BudgetTable.model_fields
|
||||
and field != "object_permission"
|
||||
)
|
||||
}
|
||||
async with prisma_client.tx() as transaction:
|
||||
tx: Final = _TransactionClient(db=transaction)
|
||||
customer_locked: Final = bool(await transaction.query_raw(_CUSTOMER_UPDATE_LOCK_SQL, data.user_id))
|
||||
end_user_table_data: Final = (
|
||||
await _typed_table(EndUserRepository(tx)).find_first(
|
||||
where={"user_id": data.user_id}, include={"litellm_budget_table": True}
|
||||
)
|
||||
if customer_locked
|
||||
else None
|
||||
)
|
||||
if end_user_table_data is None:
|
||||
raise ProxyException(
|
||||
message=f"End User Id={data.user_id} does not exist in db",
|
||||
type="not_found",
|
||||
code=404,
|
||||
param="user_id",
|
||||
)
|
||||
end_user_table_data_typed: Final = LiteLLM_EndUserTable.model_validate(end_user_table_data.model_dump())
|
||||
budget_error: Final = (
|
||||
await _customer_budget_update_error(tx, end_user_table_data_typed.budget_id, data.user_id)
|
||||
if budget_table_data and end_user_table_data_typed.budget_id is not None
|
||||
else None
|
||||
)
|
||||
if budget_error is not None:
|
||||
return budget_error
|
||||
permission_upsert: Final = (
|
||||
await prepare_object_permission_upsert(
|
||||
new_object_permission=_STR_OBJECT_DICT.validate_python(data.object_permission.model_dump()),
|
||||
existing_object_permission_id=end_user_table_data_typed.object_permission_id,
|
||||
prisma_client=tx,
|
||||
)
|
||||
if data.object_permission is not None
|
||||
else None
|
||||
)
|
||||
permission: Final = (
|
||||
await ObjectPermissionRepository(tx).table.upsert(
|
||||
where={"object_permission_id": permission_upsert.object_permission_id},
|
||||
data={"create": permission_upsert.record, "update": permission_upsert.update_record},
|
||||
)
|
||||
if permission_upsert is not None
|
||||
else None
|
||||
)
|
||||
budget_id: Final = await _write_customer_budget_update(
|
||||
tx=tx,
|
||||
budget_data=budget_table_data,
|
||||
existing_budget=end_user_table_data_typed.litellm_budget_table,
|
||||
updated_by=user_api_key_dict.user_id or litellm_proxy_admin_name,
|
||||
)
|
||||
update_data: Final = {
|
||||
**customer_table_data,
|
||||
"user_id": data.user_id,
|
||||
**({"object_permission_id": permission.object_permission_id} if permission is not None else {}),
|
||||
**({"budget_id": budget_id} if budget_id is not None else {}),
|
||||
}
|
||||
response: Final = await _typed_table(EndUserRepository(tx)).update(
|
||||
where={"user_id": data.user_id},
|
||||
data=update_data,
|
||||
include={"litellm_budget_table": True, "object_permission": True},
|
||||
)
|
||||
if response is None:
|
||||
raise ValueError(f"Failed updating customer data. User ID does not exist passed user_id={data.user_id}")
|
||||
customer_response: Final = _to_customer_response(response)
|
||||
await _evict_end_user_cache_keys(_end_user_cache_keys((data.user_id,)))
|
||||
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.exception("litellm.proxy.proxy_server.update_end_user(): Exception occured - %s", e)
|
||||
return handle_exception_on_proxy(e)
|
||||
else:
|
||||
return customer_response
|
||||
|
||||
|
||||
@router.post(
|
||||
"/customer/update",
|
||||
tags=["Customer Management"],
|
||||
|
|
@ -610,7 +789,7 @@ async def update_end_user(
|
|||
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
|
||||
) -> CustomerResponse:
|
||||
"""
|
||||
Example curl
|
||||
Example curl
|
||||
|
||||
Parameters:
|
||||
- user_id: str
|
||||
|
|
@ -629,12 +808,13 @@ async def update_end_user(
|
|||
Supported fields:
|
||||
* mcp_servers: List[str] - List of allowed MCP server IDs
|
||||
* mcp_access_groups: List[str] - List of MCP access group names
|
||||
* mcp_toolsets: List[str] - List of allowed MCP toolset IDs
|
||||
* mcp_tool_permissions: Dict[str, List[str]] - Map of server ID to allowed tool names
|
||||
* vector_stores: List[str] - List of allowed vector store IDs
|
||||
* agents: List[str] - List of allowed agent IDs
|
||||
* agent_access_groups: List[str] - List of agent access group names
|
||||
Example: {"mcp_servers": ["server_1"], "vector_stores": ["vector_store_1"]}
|
||||
IF null or {} then no object-level restrictions apply.
|
||||
Omitted permission fields retain their existing values. Supplying null or {} leaves permissions unchanged.
|
||||
|
||||
Example curl:
|
||||
```
|
||||
|
|
@ -663,114 +843,75 @@ async def update_end_user(
|
|||
```
|
||||
"""
|
||||
|
||||
from litellm.proxy.proxy_server import litellm_proxy_admin_name, prisma_client
|
||||
result: Final = await _update_single_customer(data=data, user_api_key_dict=user_api_key_dict)
|
||||
if isinstance(result, ProxyException):
|
||||
raise result
|
||||
return result
|
||||
|
||||
try:
|
||||
data_json: Final = _STR_OBJECT_DICT.validate_python(data.json())
|
||||
# get the row from db
|
||||
if prisma_client is None:
|
||||
raise Exception("Not connected to DB!")
|
||||
|
||||
sent_fields: Final = data.fields_set()
|
||||
non_default_values: Final[dict[str, object]] = {
|
||||
k: v for k, v in data_json.items() if _should_update_field(k, v, sent_fields)
|
||||
}
|
||||
async def _update_customer_result(
|
||||
data: UpdateCustomerRequest,
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
) -> CustomerUpdateResult:
|
||||
result: Final = await _update_single_customer(data=data, user_api_key_dict=user_api_key_dict)
|
||||
if isinstance(result, ProxyException):
|
||||
return CustomerUpdateResult(user_id=data.user_id, success=False, error=str(result))
|
||||
return CustomerUpdateResult(user_id=data.user_id, success=True, updated_customer=result)
|
||||
|
||||
## Get end user table data ##
|
||||
end_user_table_data: Final = await _typed_table(EndUserRepository(prisma_client)).find_first(
|
||||
where={"user_id": data.user_id}, include={"litellm_budget_table": True}
|
||||
)
|
||||
|
||||
if end_user_table_data is None:
|
||||
raise ProxyException(
|
||||
message=f"End User Id={data.user_id} does not exist in db",
|
||||
type="not_found",
|
||||
code=404,
|
||||
param="user_id",
|
||||
)
|
||||
@router.post(
|
||||
"/customer/bulk_update",
|
||||
tags=["Customer Management"],
|
||||
dependencies=[Depends(user_api_key_auth)],
|
||||
response_model=BulkUpdateCustomerResponse,
|
||||
)
|
||||
@router.post(
|
||||
"/end_user/bulk_update",
|
||||
tags=["Customer Management"],
|
||||
include_in_schema=False,
|
||||
dependencies=[Depends(user_api_key_auth)],
|
||||
response_model=BulkUpdateCustomerResponse,
|
||||
)
|
||||
async def bulk_customer_update(
|
||||
data: BulkUpdateCustomerRequest,
|
||||
user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
|
||||
) -> BulkUpdateCustomerResponse:
|
||||
"""Update up to 500 customers with the same fields as `/customer/update`. Only proxy admins can call it.
|
||||
|
||||
end_user_table_data_typed: Final = LiteLLM_EndUserTable.model_validate(end_user_table_data.model_dump())
|
||||
Each item runs in its own locked transaction, so a failed item rolls back only its own writes and
|
||||
the rest still apply. Results come back in request order with `user_id`, `success`, and either
|
||||
`updated_customer` or `error`.
|
||||
|
||||
## Get budget table data ##
|
||||
end_user_budget_table: Final = end_user_table_data_typed.litellm_budget_table
|
||||
Omitted or null permission fields keep their stored values, supplied lists replace that field,
|
||||
and empty lists or objects clear it.
|
||||
|
||||
## Get all params for budget table ##
|
||||
budget_table_data: Final = dict[str, object]()
|
||||
update_end_user_table_data: Final = dict[str, object]()
|
||||
for k, v in non_default_values.items():
|
||||
# budget_id is for linking to existing budget, not for creating new budget
|
||||
if k == "budget_id":
|
||||
update_end_user_table_data[k] = v
|
||||
elif k in LiteLLM_BudgetTable.model_fields:
|
||||
budget_table_data[k] = v
|
||||
`max_budget` only updates a budget used by that customer alone. Shared and default budgets must be
|
||||
edited through the budget endpoints. Sending both `budget_id` and `max_budget` in any item, even
|
||||
as null, fails the whole request with HTTP 422.
|
||||
|
||||
elif k in LiteLLM_EndUserTable.model_fields:
|
||||
update_end_user_table_data[k] = v
|
||||
|
||||
## Handle object permission updates (MCP servers, vector stores, etc.)
|
||||
await _handle_customer_object_permission_update(
|
||||
non_default_values=non_default_values,
|
||||
end_user_table_data_typed=end_user_table_data_typed,
|
||||
update_end_user_table_data=update_end_user_table_data,
|
||||
prisma_client=prisma_client,
|
||||
)
|
||||
|
||||
## Check if we need to create a new budget (only if budget fields are provided, not just budget_id) ##
|
||||
if budget_table_data:
|
||||
if end_user_budget_table is None:
|
||||
## Create new budget ##
|
||||
budget_table_data_record = await _typed_table(BudgetRepository(prisma_client)).create(
|
||||
data={
|
||||
**budget_table_data,
|
||||
"created_by": user_api_key_dict.user_id or litellm_proxy_admin_name,
|
||||
"updated_by": user_api_key_dict.user_id or litellm_proxy_admin_name,
|
||||
},
|
||||
include={"end_users": True},
|
||||
)
|
||||
|
||||
update_end_user_table_data["budget_id"] = budget_table_data_record.budget_id
|
||||
else:
|
||||
## Update existing budget ##
|
||||
budget_table_data_record = await _typed_table(BudgetRepository(prisma_client)).update(
|
||||
where={"budget_id": end_user_budget_table.budget_id},
|
||||
data=budget_table_data,
|
||||
)
|
||||
|
||||
## Update user table, with update params + new budget id (if set) ##
|
||||
verbose_proxy_logger.debug("/customer/update: Received data = %s", data)
|
||||
|
||||
# Ensure object_permission is not in the update data
|
||||
# It should have been converted to object_permission_id by handle_update_object_permission_common
|
||||
if "object_permission" in update_end_user_table_data:
|
||||
verbose_proxy_logger.warning(
|
||||
"object_permission still in update_end_user_table_data: %s",
|
||||
update_end_user_table_data.get("object_permission"),
|
||||
)
|
||||
update_end_user_table_data.pop("object_permission", None)
|
||||
|
||||
if data.user_id is not None and len(data.user_id) > 0:
|
||||
update_end_user_table_data["user_id"] = data.user_id
|
||||
verbose_proxy_logger.debug("In update customer, user_id condition block.")
|
||||
response: Final = await _typed_table(EndUserRepository(prisma_client)).update(
|
||||
where={"user_id": data.user_id},
|
||||
data=update_end_user_table_data,
|
||||
include={"litellm_budget_table": True, "object_permission": True},
|
||||
)
|
||||
if response is None:
|
||||
raise ValueError(f"Failed updating customer data. User ID does not exist passed user_id={data.user_id}")
|
||||
verbose_proxy_logger.debug("received response from updating prisma client. response=%s", response)
|
||||
|
||||
await _evict_end_user_cache_keys(_end_user_cache_keys((data.user_id,)))
|
||||
|
||||
return _to_customer_response(response)
|
||||
else:
|
||||
raise ValueError(f"user_id is required, passed user_id = {data.user_id}")
|
||||
|
||||
# update based on remaining passed in values
|
||||
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.exception("litellm.proxy.proxy_server.update_end_user(): Exception occured - %s", e)
|
||||
raise handle_exception_on_proxy(e)
|
||||
Example request body:
|
||||
```json
|
||||
{
|
||||
"customers": [
|
||||
{"user_id": "customer-1", "object_permission": {"mcp_toolsets": ["toolset-1"]}},
|
||||
{"user_id": "customer-2", "models": ["model-1"]}
|
||||
]
|
||||
}
|
||||
```
|
||||
"""
|
||||
results: Final = tuple(
|
||||
[
|
||||
await _update_customer_result(data=customer, user_api_key_dict=user_api_key_dict)
|
||||
for customer in data.customers
|
||||
]
|
||||
)
|
||||
successful_updates: Final = sum(result.success for result in results)
|
||||
return BulkUpdateCustomerResponse(
|
||||
results=results,
|
||||
total_requested=len(results),
|
||||
successful_updates=successful_updates,
|
||||
failed_updates=len(results) - successful_updates,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
|
|
|
|||
|
|
@ -2866,7 +2866,7 @@ async def _write_prepared_key_update_object_permission(
|
|||
|
||||
await ObjectPermissionRepository(prisma_client).table.upsert(
|
||||
where={"object_permission_id": upsert.object_permission_id},
|
||||
data={"create": upsert.record, "update": upsert.record},
|
||||
data={"create": upsert.record, "update": upsert.update_record},
|
||||
)
|
||||
return MappingProxyType({**data_json, "object_permission_id": upsert.object_permission_id})
|
||||
|
||||
|
|
|
|||
|
|
@ -989,7 +989,7 @@ async def update_organization_v2(
|
|||
where={"object_permission_id": object_permission_upsert.object_permission_id},
|
||||
data={
|
||||
"create": object_permission_upsert.record,
|
||||
"update": object_permission_upsert.record,
|
||||
"update": object_permission_upsert.update_record,
|
||||
},
|
||||
)
|
||||
if budget_updates:
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ from litellm.proxy.common_utils.auth_cache_invalidation_pubsub import evict_and_
|
|||
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache, object_permission_cache_key
|
||||
from litellm.proxy.utils import PrismaClient
|
||||
from litellm.repositories.object_permission_repository import ObjectPermissionRepository
|
||||
from litellm.repositories.prisma_protocols import DatabaseClient
|
||||
from litellm.repositories.table_repositories import MCPServerRepository
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
|
@ -78,19 +79,21 @@ async def attach_object_permission_to_dict(
|
|||
class ObjectPermissionUpsert:
|
||||
object_permission_id: str
|
||||
record: dict[str, object]
|
||||
update_record: Mapping[str, object]
|
||||
|
||||
|
||||
async def prepare_object_permission_upsert(
|
||||
new_object_permission: Mapping[str, object],
|
||||
existing_object_permission_id: str | None,
|
||||
prisma_client: PrismaClient,
|
||||
prisma_client: DatabaseClient,
|
||||
) -> ObjectPermissionUpsert:
|
||||
"""
|
||||
Read-and-merge half of an object permission upsert; performs no writes.
|
||||
|
||||
Merges the sent grants over the existing row (looked up by
|
||||
``existing_object_permission_id``, or a fresh uuid when the entity has none) and
|
||||
returns the id plus the full record to upsert. The id is pinned inside the record
|
||||
returns the id, a merged create record, and an update record of only sent grants.
|
||||
The id is pinned inside the create record
|
||||
because the column has ``@default(uuid())``, so a create without it would mint a
|
||||
different id than the one the caller links. ``mcp_tool_permissions`` is serialized
|
||||
to a JSON string to avoid GraphQL parsing issues (e.g. server IDs starting with
|
||||
|
|
@ -114,9 +117,14 @@ async def prepare_object_permission_upsert(
|
|||
existing_mcp_tool_permissions=existing_fields.get("mcp_tool_permissions"),
|
||||
prisma_client=prisma_client,
|
||||
)
|
||||
requested_fields: Final = {
|
||||
field: value
|
||||
for field, value in new_object_permission.items()
|
||||
if value is not None and field != "object_permission_id"
|
||||
}
|
||||
merged: Final[dict[str, object]] = {
|
||||
**existing_fields,
|
||||
**new_object_permission,
|
||||
**requested_fields,
|
||||
"object_permission_id": object_permission_id,
|
||||
}
|
||||
record: Final[dict[str, object]] = {
|
||||
|
|
@ -127,7 +135,15 @@ async def prepare_object_permission_upsert(
|
|||
else {}
|
||||
),
|
||||
}
|
||||
return ObjectPermissionUpsert(object_permission_id=object_permission_id, record=record)
|
||||
update_record: Final = {
|
||||
field: safe_dumps(value) if field == "mcp_tool_permissions" else value
|
||||
for field, value in requested_fields.items()
|
||||
}
|
||||
return ObjectPermissionUpsert(
|
||||
object_permission_id=object_permission_id,
|
||||
record=record,
|
||||
update_record=update_record,
|
||||
)
|
||||
|
||||
|
||||
_DECODED_JSON: Final = TypeAdapter(object)
|
||||
|
|
@ -181,7 +197,7 @@ async def handle_update_object_permission_common(
|
|||
where={"object_permission_id": upsert.object_permission_id},
|
||||
data={
|
||||
"create": upsert.record,
|
||||
"update": upsert.record,
|
||||
"update": upsert.update_record,
|
||||
},
|
||||
)
|
||||
|
||||
|
|
@ -269,7 +285,7 @@ def _mcp_server_identifier_matches(server: object, identifier: str) -> bool:
|
|||
|
||||
async def _get_db_mcp_servers_by_identifiers(
|
||||
identifiers: AbstractSet[str],
|
||||
prisma_client: PrismaClient | None,
|
||||
prisma_client: DatabaseClient | None,
|
||||
) -> "Sequence[prisma_models.LiteLLM_MCPServerTable]":
|
||||
if prisma_client is None or not identifiers:
|
||||
return []
|
||||
|
|
@ -288,7 +304,7 @@ async def _get_db_mcp_servers_by_identifiers(
|
|||
|
||||
async def _resolve_mcp_server_identifiers_to_ids(
|
||||
identifiers: AbstractSet[str],
|
||||
prisma_client: PrismaClient | None,
|
||||
prisma_client: DatabaseClient | None,
|
||||
) -> dict[str, set[str]]:
|
||||
"""
|
||||
Resolve MCP permission entries written as server_id, alias, or server_name
|
||||
|
|
@ -345,7 +361,7 @@ def _mcp_tool_permission_entries(raw: object) -> Mapping[str, frozenset[str]]:
|
|||
async def reject_ambiguous_mcp_tool_permission_keys(
|
||||
new_mcp_tool_permissions: object,
|
||||
existing_mcp_tool_permissions: object,
|
||||
prisma_client: PrismaClient | None,
|
||||
prisma_client: DatabaseClient | None,
|
||||
) -> None:
|
||||
"""
|
||||
A name or alias shared by several MCP servers cannot key ``mcp_tool_permissions``:
|
||||
|
|
|
|||
|
|
@ -563,6 +563,7 @@ model LiteLLM_VerificationToken {
|
|||
|
||||
// SELECT ... FROM "public"."LiteLLM_VerificationToken" WHERE (("public"."LiteLLM_VerificationToken"."expires" IS NULL OR "public"."LiteLLM_VerificationToken"."expires" > $1) AND "public"."LiteLLM_VerificationToken"."budget_reset_at" < $2) OFFSET $3
|
||||
@@index([budget_reset_at, expires])
|
||||
@@index([budget_id])
|
||||
}
|
||||
|
||||
model LiteLLM_JWTKeyMapping {
|
||||
|
|
@ -678,6 +679,8 @@ model LiteLLM_EndUserTable {
|
|||
litellm_budget_table LiteLLM_BudgetTable? @relation(fields: [budget_id], references: [budget_id])
|
||||
object_permission LiteLLM_ObjectPermissionTable? @relation(fields: [object_permission_id], references: [object_permission_id])
|
||||
blocked Boolean @default(false)
|
||||
|
||||
@@index([budget_id])
|
||||
}
|
||||
|
||||
// Budget and shared spend for a model access group. The groups themselves are not rows anywhere:
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
from pydantic import Field
|
||||
from pydantic import ConfigDict, Field
|
||||
|
||||
from litellm.models.budget import LiteLLM_BudgetTableFull
|
||||
from litellm.models.end_user import LiteLLM_EndUserTable
|
||||
|
|
@ -27,3 +27,21 @@ class UnblockUsersResponse(LiteLLMBaseModel):
|
|||
class DeleteCustomersResponse(LiteLLMBaseModel):
|
||||
deleted_customers: int
|
||||
message: str
|
||||
|
||||
|
||||
class CustomerUpdateResult(LiteLLMBaseModel):
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
user_id: str
|
||||
success: bool
|
||||
updated_customer: CustomerResponse | None = None
|
||||
error: str | None = None
|
||||
|
||||
|
||||
class BulkUpdateCustomerResponse(LiteLLMBaseModel):
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
results: tuple[CustomerUpdateResult, ...]
|
||||
total_requested: int
|
||||
successful_updates: int
|
||||
failed_updates: int
|
||||
|
|
|
|||
|
|
@ -563,6 +563,7 @@ model LiteLLM_VerificationToken {
|
|||
|
||||
// SELECT ... FROM "public"."LiteLLM_VerificationToken" WHERE (("public"."LiteLLM_VerificationToken"."expires" IS NULL OR "public"."LiteLLM_VerificationToken"."expires" > $1) AND "public"."LiteLLM_VerificationToken"."budget_reset_at" < $2) OFFSET $3
|
||||
@@index([budget_reset_at, expires])
|
||||
@@index([budget_id])
|
||||
}
|
||||
|
||||
model LiteLLM_JWTKeyMapping {
|
||||
|
|
@ -678,6 +679,8 @@ model LiteLLM_EndUserTable {
|
|||
litellm_budget_table LiteLLM_BudgetTable? @relation(fields: [budget_id], references: [budget_id])
|
||||
object_permission LiteLLM_ObjectPermissionTable? @relation(fields: [object_permission_id], references: [object_permission_id])
|
||||
blocked Boolean @default(false)
|
||||
|
||||
@@index([budget_id])
|
||||
}
|
||||
|
||||
// Budget and shared spend for a model access group. The groups themselves are not rows anywhere:
|
||||
|
|
|
|||
|
|
@ -94,6 +94,7 @@ GET /guardrails/submissions
|
|||
GET /guardrails/submissions/{guardrail_id}
|
||||
POST /credentials/migrate-encryption
|
||||
POST /customer/block
|
||||
POST /customer/bulk_update
|
||||
POST /customer/unblock
|
||||
POST /guardrails/apply_guardrail
|
||||
POST /guardrails/register
|
||||
|
|
|
|||
|
|
@ -139,12 +139,13 @@ def test_block_route_marks_the_customer_blocked_and_update_unblocks(gateway: Gat
|
|||
)
|
||||
|
||||
|
||||
def _assert_unseen_customer_is_blocked(gateway: Gateway, block_route: str) -> None:
|
||||
with gateway.scenario() as scenario:
|
||||
def _assert_unseen_customer_is_blocked(gateway: Gateway, tmp_path: Path, block_route: str) -> None:
|
||||
with ExitStack() as stack:
|
||||
proxy: Final = _one_callback_proxy(gateway, tmp_path, stack)
|
||||
end_user: Final = f"integration-end-user-{uuid.uuid4().hex}"
|
||||
scenario.cleanups.callback(gateway.request, "POST", "/customer/delete", {"user_ids": [end_user]})
|
||||
stack.callback(proxy.request, "POST", "/customer/delete", {"user_ids": [end_user]})
|
||||
|
||||
blocked: Final = gateway.request("POST", block_route, {"user_ids": [end_user]})
|
||||
blocked: Final = proxy.request("POST", block_route, {"user_ids": [end_user]})
|
||||
assert blocked.status_code == 200, blocked.text
|
||||
blocked_response: Final = _BlockUsersResponse.model_validate_json(blocked.content)
|
||||
assert blocked_response == _BlockUsersResponse(
|
||||
|
|
@ -153,26 +154,26 @@ def _assert_unseen_customer_is_blocked(gateway: Gateway, block_route: str) -> No
|
|||
assert eventually(lambda: _blocked_row(end_user), lambda value: value == [{"blocked": True}], seconds=15) == [
|
||||
{"blocked": True}
|
||||
]
|
||||
info: Final = gateway.request("GET", "/customer/info", params={"end_user_id": end_user})
|
||||
info: Final = proxy.request("GET", "/customer/info", params={"end_user_id": end_user})
|
||||
assert info.status_code == 200, info.text
|
||||
assert _BlockedCustomerInfo.model_validate(info.json()).blocked is True
|
||||
|
||||
updated: Final = gateway.request("POST", "/customer/update", {"user_id": end_user, "blocked": False})
|
||||
updated: Final = proxy.request("POST", "/customer/update", {"user_id": end_user, "blocked": False})
|
||||
assert updated.status_code == 200, updated.text
|
||||
assert eventually(lambda: _blocked_row(end_user), lambda value: value == [{"blocked": False}], seconds=15) == [
|
||||
{"blocked": False}
|
||||
]
|
||||
info_after_update: Final = gateway.request("GET", "/customer/info", params={"end_user_id": end_user})
|
||||
info_after_update: Final = proxy.request("GET", "/customer/info", params={"end_user_id": end_user})
|
||||
assert info_after_update.status_code == 200, info_after_update.text
|
||||
assert _BlockedCustomerInfo.model_validate(info_after_update.json()).blocked is False
|
||||
|
||||
|
||||
def test_end_user_block_route_creates_and_blocks_an_unseen_customer(gateway: Gateway) -> None:
|
||||
_assert_unseen_customer_is_blocked(gateway, "/end_user/block")
|
||||
def test_end_user_block_route_creates_and_blocks_an_unseen_customer(gateway: Gateway, tmp_path: Path) -> None:
|
||||
_assert_unseen_customer_is_blocked(gateway, tmp_path, "/end_user/block")
|
||||
|
||||
|
||||
def test_customer_block_route_creates_and_blocks_an_unseen_customer(gateway: Gateway) -> None:
|
||||
_assert_unseen_customer_is_blocked(gateway, "/customer/block")
|
||||
def test_customer_block_route_creates_and_blocks_an_unseen_customer(gateway: Gateway, tmp_path: Path) -> None:
|
||||
_assert_unseen_customer_is_blocked(gateway, tmp_path, "/customer/block")
|
||||
|
||||
|
||||
def test_listed_end_user_is_refused_and_unblock_restores_serving(gateway: Gateway, tmp_path: Path) -> None:
|
||||
|
|
@ -212,7 +213,7 @@ def test_listed_end_user_is_refused_and_unblock_restores_serving(gateway: Gatewa
|
|||
def test_blocked_end_user_is_refused_on_every_proxy_and_unblock_restores(
|
||||
gateway: Gateway, tmp_path: Path, block_route: str
|
||||
) -> None:
|
||||
pytest.skip("BUG: blocked end-user chat requests return HTTP 200 instead of HTTP 400")
|
||||
pytest.skip("BUG: another proxy keeps a customer's previous block state for up to 60 seconds after unblock")
|
||||
with (
|
||||
ExitStack() as stack,
|
||||
httpx.Client(base_url=gateway.upstream_url, timeout=5, trust_env=False) as upstream,
|
||||
|
|
@ -292,7 +293,7 @@ def test_blocked_end_user_is_refused_on_every_proxy_and_unblock_restores(
|
|||
assert _observed(upstream) == expected_observations
|
||||
|
||||
|
||||
def test_unblock_route_requires_the_blocked_user_check_callback(gateway: Gateway, tmp_path: Path) -> None:
|
||||
def test_block_and_unblock_require_the_blocked_user_check_callback(gateway: Gateway, tmp_path: Path) -> None:
|
||||
with ExitStack() as stack:
|
||||
callback_proxy: Final = _one_callback_proxy(gateway, tmp_path, stack)
|
||||
end_user: Final = f"integration-end-user-{uuid.uuid4().hex}"
|
||||
|
|
@ -310,21 +311,26 @@ def test_unblock_route_requires_the_blocked_user_check_callback(gateway: Gateway
|
|||
)
|
||||
assert _blocked_row(end_user) == [{"blocked": True}]
|
||||
|
||||
unseen_user: Final = f"integration-end-user-{uuid.uuid4().hex}"
|
||||
shared_block: Final = gateway.request("POST", "/customer/block", {"user_ids": [unseen_user]})
|
||||
assert shared_block.status_code == 400, shared_block.text
|
||||
assert shared_block.json() == shared.json(), shared_block.text
|
||||
assert _blocked_row(unseen_user) == []
|
||||
|
||||
callback_response: Final = callback_proxy.request("POST", "/customer/unblock", {"user_ids": [end_user]})
|
||||
assert callback_response.status_code == 400, callback_response.text
|
||||
assert callback_response.json() == {
|
||||
"detail": {"error": "Blocked user check was never set. This call has no effect."}
|
||||
}, callback_response.text
|
||||
assert _blocked_row(end_user) == [{"blocked": True}]
|
||||
assert callback_response.status_code == 200, callback_response.text
|
||||
assert callback_response.json() == {"blocked_users": []}, callback_response.text
|
||||
assert _blocked_row(end_user) == [{"blocked": False}]
|
||||
|
||||
|
||||
def test_block_route_returns_the_blocked_records(gateway: Gateway) -> None:
|
||||
def test_block_route_returns_the_blocked_records(gateway: Gateway, tmp_path: Path) -> None:
|
||||
end_user: Final = f"integration-end-user-{uuid.uuid4().hex}"
|
||||
with gateway.scenario() as scenario:
|
||||
created: Final = gateway.request("POST", "/customer/new", {"user_id": end_user})
|
||||
with ExitStack() as stack:
|
||||
proxy: Final = _one_callback_proxy(gateway, tmp_path, stack)
|
||||
created: Final = proxy.request("POST", "/customer/new", {"user_id": end_user})
|
||||
assert created.status_code == 200, created.text
|
||||
scenario.cleanups.callback(gateway.request, "POST", "/customer/delete", {"user_ids": [end_user]})
|
||||
blocked: Final = gateway.request("POST", "/customer/block", {"user_ids": [end_user]})
|
||||
stack.callback(proxy.request, "POST", "/customer/delete", {"user_ids": [end_user]})
|
||||
blocked: Final = proxy.request("POST", "/customer/block", {"user_ids": [end_user]})
|
||||
assert blocked.status_code == 200, blocked.text
|
||||
payload: Final = object_value(blocked.json())
|
||||
blocked_users: Final = payload["blocked_users"]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,89 @@
|
|||
import os
|
||||
from pathlib import Path
|
||||
from typing import Final
|
||||
|
||||
import psycopg
|
||||
from psycopg import sql
|
||||
from pydantic import BaseModel, Field, TypeAdapter
|
||||
|
||||
from litellm.proxy.management_endpoints.customer_endpoints import _CUSTOMER_SHARED_BUDGET_SQL
|
||||
|
||||
_REPOSITORY: Final = Path(__file__).resolve().parents[4]
|
||||
_MIGRATIONS: Final = _REPOSITORY / "litellm-proxy-extras" / "litellm_proxy_extras" / "migrations"
|
||||
_INDEX_MIGRATIONS: Final = (
|
||||
"20261018000100_customer_budget_reference_index",
|
||||
"20261018000200_key_budget_reference_index",
|
||||
"20261018000300_key_customer_budget_reference_index",
|
||||
)
|
||||
_REFERENCE_TABLES: Final = (
|
||||
"LiteLLM_OrganizationTable",
|
||||
"LiteLLM_ProjectTable",
|
||||
"LiteLLM_TagTable",
|
||||
"LiteLLM_ModelAccessGroupBudgetTable",
|
||||
"LiteLLM_TeamMembership",
|
||||
"LiteLLM_OrganizationMembership",
|
||||
"LiteLLM_TeamTable",
|
||||
)
|
||||
|
||||
|
||||
class _PlanNode(BaseModel, frozen=True):
|
||||
rows: float = Field(default=0, alias="Actual Rows")
|
||||
removed: float = Field(default=0, alias="Rows Removed by Filter")
|
||||
loops: float = Field(default=0, alias="Actual Loops")
|
||||
plans: tuple["_PlanNode", ...] = Field(default=(), alias="Plans")
|
||||
|
||||
def examined_rows(self) -> float:
|
||||
return (self.rows + self.removed) * self.loops + sum(plan.examined_rows() for plan in self.plans)
|
||||
|
||||
|
||||
class _QueryPlan(BaseModel, frozen=True):
|
||||
plan: _PlanNode = Field(alias="Plan")
|
||||
|
||||
|
||||
def test_customer_budget_guard_keeps_generic_plan_work_bounded() -> None:
|
||||
with psycopg.connect(os.environ["DATABASE_URL"], autocommit=True) as connection:
|
||||
connection.execute('CREATE TABLE "LiteLLM_BudgetTable" (budget_id text PRIMARY KEY)')
|
||||
connection.execute('CREATE TABLE "LiteLLM_EndUserTable" (user_id text PRIMARY KEY, budget_id text)')
|
||||
connection.execute(
|
||||
'CREATE TABLE "LiteLLM_VerificationToken" (token text PRIMARY KEY, budget_id text, metadata jsonb)'
|
||||
)
|
||||
for table in _REFERENCE_TABLES:
|
||||
connection.execute(
|
||||
sql.SQL("CREATE TABLE {} (budget_id text, metadata jsonb)").format(sql.Identifier(table))
|
||||
)
|
||||
connection.execute("INSERT INTO \"LiteLLM_BudgetTable\" SELECT 'budget-'||n FROM generate_series(0,500) n")
|
||||
connection.execute(
|
||||
"INSERT INTO \"LiteLLM_EndUserTable\" SELECT 'customer-'||n,'budget-'||n FROM generate_series(0,500) n"
|
||||
)
|
||||
key_count: Final = 50_000
|
||||
connection.execute(
|
||||
"INSERT INTO \"LiteLLM_VerificationToken\" SELECT 'key-'||n,NULL,"
|
||||
"jsonb_build_object('end_user_budget_id','unrelated-budget') FROM generate_series(1,%s) n",
|
||||
(key_count,),
|
||||
)
|
||||
for migration in _INDEX_MIGRATIONS:
|
||||
connection.execute((_MIGRATIONS / migration / "migration.sql").read_text())
|
||||
connection.execute("ANALYZE")
|
||||
connection.execute("SET plan_cache_mode = force_generic_plan")
|
||||
connection.execute("PREPARE guard(text,text) AS " + _CUSTOMER_SHARED_BUDGET_SQL)
|
||||
plan_row: Final = connection.execute(
|
||||
"EXPLAIN (ANALYZE, FORMAT JSON) EXECUTE guard('budget-0','customer-0')"
|
||||
).fetchone()
|
||||
assert plan_row is not None
|
||||
plan: Final = TypeAdapter(tuple[_QueryPlan, ...]).validate_python(plan_row[0])[0].plan
|
||||
assert plan.examined_rows() < key_count / 100
|
||||
assert connection.execute("EXECUTE guard('budget-0','customer-0')").fetchall() == []
|
||||
for metadata in ('{"end_user_budget_id": ["budget-0"]}', '{"end_user_budget_id": null}'):
|
||||
connection.execute(
|
||||
"INSERT INTO \"LiteLLM_VerificationToken\" VALUES ('malformed',NULL,%s::jsonb)", (metadata,)
|
||||
)
|
||||
assert connection.execute("EXECUTE guard('budget-0','customer-0')").fetchall() == []
|
||||
connection.execute("DELETE FROM \"LiteLLM_VerificationToken\" WHERE token='malformed'")
|
||||
connection.execute(
|
||||
'INSERT INTO "LiteLLM_VerificationToken" VALUES '
|
||||
"('metadata-budget',NULL,jsonb_build_object('end_user_budget_id','budget-0'))"
|
||||
)
|
||||
assert connection.execute("EXECUTE guard('budget-0','customer-0')").fetchall() == [("budget-0",)]
|
||||
connection.execute("DELETE FROM \"LiteLLM_VerificationToken\" WHERE token='metadata-budget'")
|
||||
connection.execute("INSERT INTO \"LiteLLM_VerificationToken\" VALUES ('direct-budget','budget-0','{}')")
|
||||
assert connection.execute("EXECUTE guard('budget-0','customer-0')").fetchall() == [("budget-0",)]
|
||||
|
|
@ -13,7 +13,7 @@ from fastapi import HTTPException
|
|||
from fastapi.testclient import TestClient
|
||||
from starlette.datastructures import Headers
|
||||
|
||||
from litellm.caching.caching import DualCache
|
||||
from litellm.caching.dual_cache import DualCache
|
||||
from litellm.constants import MCP_ALL_TOOLS_WILDCARD
|
||||
from litellm.models.access_group import LiteLLM_AccessGroupTable
|
||||
from litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp import (
|
||||
|
|
@ -22,6 +22,7 @@ from litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp import (
|
|||
_agent_capped_servers,
|
||||
is_mcp_admitted_user_subject,
|
||||
)
|
||||
from litellm.proxy._experimental.mcp_server.mcp_server_manager import MCPServerManager
|
||||
from litellm.proxy._types import (
|
||||
LiteLLM_ObjectPermissionTable,
|
||||
LiteLLM_TeamTable,
|
||||
|
|
@ -32,6 +33,8 @@ from litellm.proxy._types import (
|
|||
UserAPIKeyAuth,
|
||||
)
|
||||
from litellm.types.agents import AgentCaller
|
||||
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
||||
from litellm.types.mcp_server.mcp_toolset import MCPToolset
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
|
|
@ -10656,26 +10659,32 @@ class TestEntitlementFaultSemantics:
|
|||
assert set(allowed) == {"srv1"}
|
||||
|
||||
|
||||
async def _cache_with_end_user(end_user_id, *, mcp_tool_permissions=None, object_permission_id=None):
|
||||
async def _cache_with_end_user(
|
||||
end_user_id: str,
|
||||
*,
|
||||
mcp_tool_permissions: dict[str, list[str]] | None = None,
|
||||
object_permission_id: str | None = None,
|
||||
object_permission_override: LiteLLM_ObjectPermissionTable | None = None,
|
||||
) -> DualCache:
|
||||
"""A real DualCache already holding the end user row, so ``get_end_user_object`` answers from
|
||||
cache and no ``litellm.`` internal has to be patched. ``object_permission_id`` without a
|
||||
permission body models a row that NAMES an entitlement the DB then fails to serve."""
|
||||
from litellm.caching.dual_cache import DualCache
|
||||
from litellm.models.end_user import LiteLLM_EndUserTable
|
||||
from litellm.proxy.common_utils.user_api_key_cache import end_user_cache_key
|
||||
|
||||
cache = DualCache()
|
||||
permission: Final = object_permission_override or (
|
||||
LiteLLM_ObjectPermissionTable(object_permission_id="op-eu", mcp_tool_permissions=mcp_tool_permissions)
|
||||
if mcp_tool_permissions
|
||||
else None
|
||||
)
|
||||
cache: Final = DualCache()
|
||||
await cache.async_set_cache(
|
||||
key=end_user_cache_key(end_user_id),
|
||||
value=LiteLLM_EndUserTable(
|
||||
user_id=end_user_id,
|
||||
blocked=False,
|
||||
object_permission_id=object_permission_id or ("op-eu" if mcp_tool_permissions else None),
|
||||
object_permission=LiteLLM_ObjectPermissionTable(
|
||||
object_permission_id="op-eu", mcp_tool_permissions=mcp_tool_permissions
|
||||
)
|
||||
if mcp_tool_permissions
|
||||
else None,
|
||||
object_permission_id=object_permission_id or (permission.object_permission_id if permission else None),
|
||||
object_permission=permission,
|
||||
),
|
||||
)
|
||||
return cache
|
||||
|
|
@ -10686,6 +10695,207 @@ class TestEndUserToolCeiling:
|
|||
"""The end user (customer) level narrows the TOOLS axis exactly as it narrows the servers axis,
|
||||
so `object_permission.mcp_tool_permissions` on `/customer/new` is enforced, not just stored."""
|
||||
|
||||
@pytest.mark.parametrize("require_defined", (False, True))
|
||||
@pytest.mark.parametrize(
|
||||
"key_servers,expected_servers", ((("server-a", "server-b"), ("server-a",)), (("server-b",), ()))
|
||||
)
|
||||
async def test_end_user_toolsets_cap_shared_key_servers(
|
||||
self, require_defined: bool, key_servers: tuple[str, ...], expected_servers: tuple[str, ...]
|
||||
) -> None:
|
||||
permission: Final = LiteLLM_ObjectPermissionTable(object_permission_id="op-eu", mcp_toolsets=["toolset-a"])
|
||||
cache: Final = await _cache_with_end_user("eu-1", object_permission_override=permission)
|
||||
await cache.async_set_cache(key="toolset_perms:toolset-a", value={"server-a": ["read"]})
|
||||
auth: Final = UserAPIKeyAuth(
|
||||
api_key="test-key",
|
||||
end_user_id="eu-1",
|
||||
object_permission=LiteLLM_ObjectPermissionTable(
|
||||
object_permission_id="op-key", mcp_servers=list(key_servers)
|
||||
),
|
||||
)
|
||||
with (
|
||||
_entitlement_fault_globals(user_api_key_cache=cache),
|
||||
patch(
|
||||
"litellm.proxy.proxy_server.general_settings", {"require_end_user_mcp_access_defined": require_defined}
|
||||
),
|
||||
):
|
||||
allowed_servers: Final = await MCPRequestHandler.get_allowed_mcp_servers(auth)
|
||||
allowed_tools: Final = await MCPRequestHandler.get_allowed_tools_for_server("server-a", auth)
|
||||
read_allowed: Final = await MCPRequestHandler.is_tool_allowed_for_server("read", "server-a", auth)
|
||||
write_allowed: Final = await MCPRequestHandler.is_tool_allowed_for_server("write", "server-a", auth)
|
||||
assert set(allowed_servers) == set(expected_servers)
|
||||
assert allowed_tools == ["read"]
|
||||
assert read_allowed is True
|
||||
assert write_allowed is False
|
||||
|
||||
async def test_end_user_toolset_servers_union_with_existing_grants(self) -> None:
|
||||
permission: Final = LiteLLM_ObjectPermissionTable(
|
||||
object_permission_id="op-eu",
|
||||
mcp_servers=["direct"],
|
||||
mcp_access_groups=["readers"],
|
||||
mcp_tool_permissions={"inline": ["read"]},
|
||||
mcp_toolsets=["toolset-a"],
|
||||
)
|
||||
cache: Final = await _cache_with_end_user("eu-1", object_permission_override=permission)
|
||||
await cache.async_set_cache(key="toolset_perms:toolset-a", value={"toolset": ["read"]})
|
||||
manager: Final = MCPServerManager()
|
||||
manager.config_mcp_servers = {
|
||||
"group": MCPServer(server_id="group", name="group", transport="http", access_groups=["readers"]),
|
||||
}
|
||||
auth: Final = UserAPIKeyAuth(
|
||||
api_key="test-key",
|
||||
end_user_id="eu-1",
|
||||
object_permission=LiteLLM_ObjectPermissionTable(
|
||||
object_permission_id="op-key",
|
||||
mcp_servers=["direct", "group", "inline", "toolset", "other"],
|
||||
),
|
||||
)
|
||||
with (
|
||||
_entitlement_fault_globals(user_api_key_cache=cache),
|
||||
patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager", manager),
|
||||
):
|
||||
allowed_servers: Final = await MCPRequestHandler.get_allowed_mcp_servers(auth)
|
||||
assert set(allowed_servers) == {"direct", "group", "inline", "toolset"}
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"toolset_rows",
|
||||
((), (MCPToolset(toolset_id="toolset-a", toolset_name="Empty", tools=[]),)),
|
||||
ids=("missing-toolset", "empty-toolset"),
|
||||
)
|
||||
async def test_end_user_unresolvable_toolsets_deny_shared_key_servers(
|
||||
self, toolset_rows: tuple[MCPToolset, ...]
|
||||
) -> None:
|
||||
permission: Final = LiteLLM_ObjectPermissionTable(object_permission_id="op-eu", mcp_toolsets=["toolset-a"])
|
||||
cache: Final = await _cache_with_end_user("eu-1", object_permission_override=permission)
|
||||
prisma: Final = MagicMock()
|
||||
prisma.configure_mock(**{"db.litellm_mcptoolsettable.find_many": AsyncMock(return_value=list(toolset_rows))})
|
||||
auth: Final = UserAPIKeyAuth(
|
||||
api_key="test-key",
|
||||
end_user_id="eu-1",
|
||||
object_permission=LiteLLM_ObjectPermissionTable(object_permission_id="op-key", mcp_servers=["server-a"]),
|
||||
)
|
||||
with _entitlement_fault_globals(prisma_client=prisma, user_api_key_cache=cache):
|
||||
with pytest.raises(UnloadableEntitlementError):
|
||||
await MCPRequestHandler._get_allowed_mcp_servers_for_end_user(auth)
|
||||
allowed_servers: Final = await MCPRequestHandler.get_allowed_mcp_servers(auth)
|
||||
assert allowed_servers == []
|
||||
|
||||
async def test_end_user_toolset_ceiling_reads_fresh_policy(self) -> None:
|
||||
permission: Final = LiteLLM_ObjectPermissionTable(object_permission_id="op-eu", mcp_toolsets=["toolset-a"])
|
||||
cache: Final = await _cache_with_end_user("eu-1", object_permission_override=permission)
|
||||
await cache.async_set_cache(key="toolset_perms:toolset-a", value={"old-server": ["read"]})
|
||||
prisma: Final = MagicMock()
|
||||
prisma.configure_mock(
|
||||
**{
|
||||
"writer_db.litellm_mcptoolsettable.find_many": AsyncMock(
|
||||
return_value=[
|
||||
MCPToolset(
|
||||
toolset_id="toolset-a",
|
||||
toolset_name="Updated",
|
||||
tools=[
|
||||
{"server_id": "new-server", "tool_name": "read"},
|
||||
],
|
||||
),
|
||||
]
|
||||
),
|
||||
}
|
||||
)
|
||||
auth: Final = UserAPIKeyAuth(
|
||||
api_key="test-key",
|
||||
end_user_id="eu-1",
|
||||
object_permission=LiteLLM_ObjectPermissionTable(
|
||||
object_permission_id="op-key",
|
||||
mcp_servers=["old-server", "new-server"],
|
||||
),
|
||||
).model_copy(update={"requires_fresh_policy": True})
|
||||
with _entitlement_fault_globals(prisma_client=prisma, user_api_key_cache=cache):
|
||||
allowed_servers: Final = await MCPRequestHandler._get_allowed_mcp_servers_for_end_user(auth)
|
||||
assert allowed_servers == ["new-server"]
|
||||
|
||||
async def test_end_user_toolset_writer_fault_does_not_drop_ceiling(self) -> None:
|
||||
permission: Final = LiteLLM_ObjectPermissionTable(object_permission_id="op-eu", mcp_toolsets=["toolset-a"])
|
||||
cache: Final = await _cache_with_end_user("eu-1", object_permission_override=permission)
|
||||
await cache.async_set_cache(key="toolset_perms:toolset-a", value={"old-server": ["read"]})
|
||||
prisma: Final = MagicMock()
|
||||
prisma.configure_mock(
|
||||
**{
|
||||
"writer_db.litellm_mcptoolsettable.find_many": AsyncMock(
|
||||
side_effect=RuntimeError("writer unavailable")
|
||||
),
|
||||
}
|
||||
)
|
||||
auth: Final = UserAPIKeyAuth(
|
||||
api_key="test-key",
|
||||
end_user_id="eu-1",
|
||||
object_permission=LiteLLM_ObjectPermissionTable(
|
||||
object_permission_id="op-key", mcp_servers=["other-server"]
|
||||
),
|
||||
).model_copy(update={"requires_fresh_policy": True})
|
||||
with _entitlement_fault_globals(prisma_client=prisma, user_api_key_cache=cache):
|
||||
allowed_servers: Final = await MCPRequestHandler.get_allowed_mcp_servers(auth)
|
||||
assert allowed_servers == []
|
||||
|
||||
async def test_end_user_access_group_writer_fault_does_not_drop_toolset_ceiling(self) -> None:
|
||||
permission: Final = LiteLLM_ObjectPermissionTable(
|
||||
object_permission_id="op-eu", mcp_toolsets=["toolset-a"], mcp_access_groups=["group-x"]
|
||||
)
|
||||
cache: Final = await _cache_with_end_user("eu-1", object_permission_override=permission)
|
||||
prisma: Final = MagicMock()
|
||||
prisma.configure_mock(
|
||||
**{
|
||||
"writer_db.litellm_mcptoolsettable.find_many": AsyncMock(
|
||||
return_value=[
|
||||
MCPToolset(
|
||||
toolset_id="toolset-a",
|
||||
toolset_name="Read only",
|
||||
tools=[{"server_id": "server-a", "tool_name": "read"}],
|
||||
),
|
||||
]
|
||||
),
|
||||
"writer_db.litellm_mcpservertable.find_many": AsyncMock(side_effect=RuntimeError("writer unavailable")),
|
||||
}
|
||||
)
|
||||
auth: Final = UserAPIKeyAuth(
|
||||
api_key="test-key",
|
||||
end_user_id="eu-1",
|
||||
object_permission=LiteLLM_ObjectPermissionTable(
|
||||
object_permission_id="op-key", mcp_servers=["server-a", "server-b"]
|
||||
),
|
||||
).model_copy(update={"requires_fresh_policy": True})
|
||||
with _entitlement_fault_globals(prisma_client=prisma, user_api_key_cache=cache):
|
||||
allowed_servers: Final = await MCPRequestHandler.get_allowed_mcp_servers(auth)
|
||||
assert "server-b" not in allowed_servers
|
||||
|
||||
async def test_end_user_mcp_opt_out_overrides_toolset_servers(self) -> None:
|
||||
permission: Final = LiteLLM_ObjectPermissionTable(
|
||||
object_permission_id="op-eu",
|
||||
mcp_servers=[SpecialMCPServerNames.no_mcp_servers.value],
|
||||
mcp_toolsets=["toolset-a"],
|
||||
)
|
||||
cache: Final = await _cache_with_end_user("eu-1", object_permission_override=permission)
|
||||
await cache.async_set_cache(key="toolset_perms:toolset-a", value={"server-a": ["read"]})
|
||||
auth: Final = UserAPIKeyAuth(
|
||||
api_key="test-key",
|
||||
end_user_id="eu-1",
|
||||
object_permission=LiteLLM_ObjectPermissionTable(object_permission_id="op-key", mcp_servers=["server-a"]),
|
||||
)
|
||||
with _entitlement_fault_globals(user_api_key_cache=cache):
|
||||
allowed_servers: Final = await MCPRequestHandler.get_allowed_mcp_servers(auth)
|
||||
assert allowed_servers == []
|
||||
|
||||
async def test_end_user_empty_permission_keeps_inherited_servers(self) -> None:
|
||||
cache: Final = await _cache_with_end_user(
|
||||
"eu-1",
|
||||
object_permission_override=LiteLLM_ObjectPermissionTable(object_permission_id="op-eu"),
|
||||
)
|
||||
auth: Final = UserAPIKeyAuth(
|
||||
api_key="test-key",
|
||||
end_user_id="eu-1",
|
||||
object_permission=LiteLLM_ObjectPermissionTable(object_permission_id="op-key", mcp_servers=["server-a"]),
|
||||
)
|
||||
with _entitlement_fault_globals(user_api_key_cache=cache):
|
||||
allowed_servers: Final = await MCPRequestHandler.get_allowed_mcp_servers(auth)
|
||||
assert allowed_servers == ["server-a"]
|
||||
|
||||
async def test_end_user_tool_permissions_intersect_key_tools(self):
|
||||
auth = _key_auth_reaching("srv1", tools=["tool_a", "tool_b"], end_user_id="eu-1")
|
||||
cache = await _cache_with_end_user("eu-1", mcp_tool_permissions={"srv1": ["tool_a"]})
|
||||
|
|
|
|||
|
|
@ -20427,3 +20427,114 @@ async def test_legacy_upstream_elicitation_rejects_modern_downstream_without_con
|
|||
finally:
|
||||
active_mcp_request_ctx_var.reset(request_token)
|
||||
legacy_server.active_mcp_session_var.reset(session_token)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("surface", ["manager", "mcp", "rest"])
|
||||
@pytest.mark.parametrize("admin", [False, True])
|
||||
@pytest.mark.parametrize("permission_state", ["denied", "stale-grants", "unreadable"])
|
||||
async def test_customer_denial_blocks_operator_open_servers(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
surface: Literal["manager", "mcp", "rest"],
|
||||
admin: bool,
|
||||
permission_state: Literal["denied", "stale-grants", "unreadable"],
|
||||
) -> None:
|
||||
from starlette.requests import Request
|
||||
|
||||
from litellm.models.end_user import LiteLLM_EndUserTable
|
||||
from litellm.proxy import proxy_server
|
||||
from litellm.proxy._experimental.mcp_server import mcp_server_manager, operations, rest_endpoints
|
||||
from litellm.proxy.common_utils.user_api_key_cache import end_user_cache_key
|
||||
|
||||
manager: Final = MCPServerManager()
|
||||
server: Final = MCPServer(
|
||||
server_id="operator-open", name="operator_open", transport=MCPTransport.http, allow_all_keys=True
|
||||
)
|
||||
manager.config_mcp_servers = {server.server_id: server}
|
||||
permission: Final = LiteLLM_ObjectPermissionTable(
|
||||
object_permission_id="customer-denial",
|
||||
mcp_servers=["no-mcp-servers"],
|
||||
mcp_tool_permissions={server.server_id: ["delete"]} if permission_state == "stale-grants" else {},
|
||||
mcp_toolsets=["stale-toolset"] if permission_state == "stale-grants" else [],
|
||||
mcp_access_groups=["stale-group"] if permission_state == "stale-grants" else [],
|
||||
)
|
||||
cache: Final = DualCache()
|
||||
await cache.async_set_cache(
|
||||
key=end_user_cache_key("denied-customer"),
|
||||
value=LiteLLM_EndUserTable(
|
||||
user_id="denied-customer",
|
||||
blocked=False,
|
||||
object_permission_id=permission.object_permission_id,
|
||||
object_permission=None if permission_state == "unreadable" else permission,
|
||||
),
|
||||
)
|
||||
auth: Final = UserAPIKeyAuth(
|
||||
api_key="test-key",
|
||||
end_user_id="denied-customer",
|
||||
user_role=LitellmUserRoles.PROXY_ADMIN if admin else None,
|
||||
object_permission=None if admin else LiteLLM_ObjectPermissionTable(
|
||||
object_permission_id="key-grant", mcp_servers=[server.server_id]
|
||||
),
|
||||
).model_copy(update={"via_virtual_key": True})
|
||||
_catalog_database(monkeypatch, AsyncMock(return_value=[]))
|
||||
monkeypatch.setattr(proxy_server, "user_api_key_cache", cache)
|
||||
monkeypatch.setattr(proxy_server, "proxy_logging_obj", ProxyLogging(user_api_key_cache=cache))
|
||||
monkeypatch.setattr(proxy_server, "general_settings", {})
|
||||
monkeypatch.setattr(mcp_server_manager, "global_mcp_server_manager", manager)
|
||||
monkeypatch.setattr(operations, "global_mcp_server_manager", manager)
|
||||
monkeypatch.setattr(rest_endpoints, "global_mcp_server_manager", manager)
|
||||
|
||||
if surface == "manager":
|
||||
assert await manager.get_allowed_mcp_servers(auth) == []
|
||||
elif surface == "mcp":
|
||||
assert await operations._get_allowed_mcp_servers(auth, None) == []
|
||||
else:
|
||||
request: Final = Request({"type": "http", "method": "POST", "path": "/mcp-rest/tools/call", "headers": []})
|
||||
with pytest.raises(HTTPException) as denied:
|
||||
await rest_endpoints._resolve_allowed_mcp_servers_with_ip_filter(request, auth, server.server_id)
|
||||
assert denied.value.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("customer", ["absent", "unrestricted", "empty-group", "limited"])
|
||||
@pytest.mark.parametrize("admin", [False, True])
|
||||
async def test_customer_without_explicit_deny_keeps_operator_open_servers(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
customer: Literal["absent", "unrestricted", "empty-group", "limited"],
|
||||
admin: bool,
|
||||
) -> None:
|
||||
from litellm.models.end_user import LiteLLM_EndUserTable
|
||||
from litellm.proxy import proxy_server
|
||||
from litellm.proxy._experimental.mcp_server import mcp_server_manager
|
||||
from litellm.proxy.common_utils.user_api_key_cache import end_user_cache_key
|
||||
|
||||
manager: Final = MCPServerManager()
|
||||
server: Final = MCPServer(
|
||||
server_id="operator-open", name="operator_open", transport=MCPTransport.http, allow_all_keys=True
|
||||
)
|
||||
manager.config_mcp_servers = {server.server_id: server}
|
||||
cache: Final = DualCache()
|
||||
await cache.async_set_cache(
|
||||
key=end_user_cache_key("unrestricted-customer"),
|
||||
value=LiteLLM_EndUserTable(
|
||||
user_id="unrestricted-customer",
|
||||
blocked=False,
|
||||
object_permission=LiteLLM_ObjectPermissionTable(
|
||||
object_permission_id="ordinary-customer",
|
||||
mcp_servers=["another-server"] if customer == "limited" else [],
|
||||
mcp_access_groups=["empty-group"] if customer == "empty-group" else [],
|
||||
),
|
||||
),
|
||||
)
|
||||
auth: Final = UserAPIKeyAuth(
|
||||
api_key="test-key",
|
||||
end_user_id=None if customer == "absent" else "unrestricted-customer",
|
||||
user_role=LitellmUserRoles.PROXY_ADMIN if admin else None,
|
||||
).model_copy(update={"via_virtual_key": True})
|
||||
_catalog_database(monkeypatch, AsyncMock(return_value=[]))
|
||||
monkeypatch.setattr(proxy_server, "user_api_key_cache", cache)
|
||||
monkeypatch.setattr(proxy_server, "proxy_logging_obj", ProxyLogging(user_api_key_cache=cache))
|
||||
monkeypatch.setattr(proxy_server, "general_settings", {})
|
||||
monkeypatch.setattr(mcp_server_manager, "global_mcp_server_manager", manager)
|
||||
|
||||
assert await manager.get_allowed_mcp_servers(auth) == [server.server_id]
|
||||
|
|
|
|||
|
|
@ -1485,10 +1485,10 @@ async def test_agent_permissions_are_written_atomically_with_the_registration(op
|
|||
permission: Final = write.call_args.kwargs["data"]["object_permission"][
|
||||
"create" if operation == "create" else "update"
|
||||
]
|
||||
assert permission["models"] == ["new"]
|
||||
if operation != "create":
|
||||
assert permission["mcp_servers"] == ["slack"]
|
||||
assert permission["object_permission_id"] == "permissions"
|
||||
if operation == "create":
|
||||
assert permission == {"object_permission_id": permission["object_permission_id"], "models": ["new"]}
|
||||
else:
|
||||
assert permission == {"models": ["new"]}
|
||||
client.db.litellm_objectpermissiontable.update.assert_not_called()
|
||||
client.db.litellm_objectpermissiontable.create.assert_not_called()
|
||||
|
||||
|
|
|
|||
|
|
@ -47,6 +47,15 @@ def mock_budget_table():
|
|||
return MagicMock(spec=LiteLLM_BudgetTable)
|
||||
|
||||
|
||||
def _run_in_locked_transaction(prisma_client: MagicMock) -> None:
|
||||
prisma_client.configure_mock(
|
||||
**{
|
||||
"db.query_raw": AsyncMock(return_value=({"user_id": "test-user"},)),
|
||||
"tx.return_value.__aenter__": AsyncMock(return_value=prisma_client.db),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("litellm.proxy.proxy_server.prisma_client")
|
||||
@patch("litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin")
|
||||
|
|
@ -69,6 +78,7 @@ async def test_update_customer_with_budget_id(
|
|||
mock_prisma_client.db.litellm_endusertable.find_first = AsyncMock(
|
||||
return_value=mock_existing_customer
|
||||
)
|
||||
_run_in_locked_transaction(mock_prisma_client)
|
||||
|
||||
mock_updated_user = MagicMock()
|
||||
mock_updated_user.model_dump.return_value = {
|
||||
|
|
@ -125,6 +135,7 @@ async def test_update_customer_creates_budget_with_proper_relations(
|
|||
mock_prisma_client.db.litellm_endusertable.find_first = AsyncMock(
|
||||
return_value=mock_existing_customer
|
||||
)
|
||||
_run_in_locked_transaction(mock_prisma_client)
|
||||
|
||||
# Mock budget creation
|
||||
mock_created_budget = MagicMock()
|
||||
|
|
@ -183,6 +194,7 @@ async def test_update_customer_creates_budget_with_required_fields(
|
|||
mock_prisma_client.db.litellm_endusertable.find_first = AsyncMock(
|
||||
return_value=mock_existing_customer
|
||||
)
|
||||
_run_in_locked_transaction(mock_prisma_client)
|
||||
|
||||
# Mock budget creation
|
||||
mock_created_budget = MagicMock()
|
||||
|
|
@ -248,6 +260,7 @@ async def test_update_customer_budget_creation_with_fallback_admin(
|
|||
mock_prisma_client.db.litellm_endusertable.find_first = AsyncMock(
|
||||
return_value=mock_existing_customer
|
||||
)
|
||||
_run_in_locked_transaction(mock_prisma_client)
|
||||
|
||||
# Mock budget creation
|
||||
mock_created_budget = MagicMock()
|
||||
|
|
@ -283,67 +296,6 @@ async def test_update_customer_budget_creation_with_fallback_admin(
|
|||
assert creation_data["updated_by"] == "admin"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("litellm.proxy.proxy_server.prisma_client")
|
||||
@patch("litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin")
|
||||
async def test_update_customer_with_budget_id_and_creation_fields(
|
||||
mock_prisma_client, mock_user_api_key_dict, mock_existing_customer
|
||||
):
|
||||
"""
|
||||
Test customer update when both budget_id and budget creation fields are provided.
|
||||
|
||||
When both linking (budget_id) and creation fields are provided, the system
|
||||
should prioritize creating a new budget and assign its ID to the customer.
|
||||
"""
|
||||
# Arrange
|
||||
mock_existing_customer.model_dump.return_value = {
|
||||
"user_id": "test-user",
|
||||
"blocked": False,
|
||||
"litellm_budget_table": None,
|
||||
}
|
||||
|
||||
mock_prisma_client.db.litellm_endusertable.find_first = AsyncMock(
|
||||
return_value=mock_existing_customer
|
||||
)
|
||||
|
||||
# Mock budget creation
|
||||
mock_created_budget = MagicMock()
|
||||
mock_created_budget.budget_id = "new-budget-combo"
|
||||
mock_prisma_client.db.litellm_budgettable.create = AsyncMock(
|
||||
return_value=mock_created_budget
|
||||
)
|
||||
|
||||
# Mock end user update
|
||||
mock_updated_user = MagicMock()
|
||||
mock_updated_user.model_dump.return_value = {"user_id": "test-user", "blocked": False}
|
||||
mock_prisma_client.db.litellm_endusertable.update = AsyncMock(
|
||||
return_value=mock_updated_user
|
||||
)
|
||||
|
||||
# Create update request with both budget_id and budget creation fields
|
||||
update_request = UpdateCustomerRequest(
|
||||
user_id="test-user",
|
||||
budget_id="existing-budget-link", # For linking to existing budget
|
||||
max_budget=300.0, # This should trigger new budget creation
|
||||
rpm_limit=500, # Use valid budget field
|
||||
)
|
||||
|
||||
# Act
|
||||
await update_end_user(update_request, mock_user_api_key_dict)
|
||||
|
||||
# Assert
|
||||
# Verify budget creation occurred (because max_budget was provided)
|
||||
mock_prisma_client.db.litellm_budgettable.create.assert_called_once()
|
||||
|
||||
# Verify end user update was called
|
||||
mock_prisma_client.db.litellm_endusertable.update.assert_called_once()
|
||||
call_args = mock_prisma_client.db.litellm_endusertable.update.call_args
|
||||
|
||||
# The update data should contain budget_id from the created budget, not the original budget_id
|
||||
update_data = call_args[1]["data"]
|
||||
assert update_data["budget_id"] == "new-budget-combo" # From created budget
|
||||
|
||||
|
||||
def test_new_budget_request_sets_budget_reset_at_when_duration_provided():
|
||||
"""
|
||||
Test that new_budget_request auto-populates budget_reset_at when
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1138,7 +1138,7 @@ async def test_key_update_prepares_existing_object_permission_without_writing():
|
|||
assert result == {"user_id": "user123", "object_permission_id": "existing_perm_id_123"}
|
||||
permission_upsert.assert_awaited_once_with(
|
||||
where={"object_permission_id": "existing_perm_id_123"},
|
||||
data={"create": upsert.record, "update": upsert.record},
|
||||
data={"create": upsert.record, "update": {"vector_stores": ["new_store"]}},
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -1182,7 +1182,7 @@ async def test_key_update_prepares_json_object_permission_and_upserts_new_row():
|
|||
assert result["object_permission_id"] == upsert.object_permission_id
|
||||
permission_upsert.assert_awaited_once_with(
|
||||
where={"object_permission_id": upsert.object_permission_id},
|
||||
data={"create": upsert.record, "update": upsert.record},
|
||||
data={"create": upsert.record, "update": {"vector_stores": ["brand_new_store"]}},
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -1225,7 +1225,7 @@ async def test_key_update_recreates_missing_object_permission_with_existing_id()
|
|||
)
|
||||
permission_upsert.assert_awaited_once_with(
|
||||
where={"object_permission_id": "missing_perm_id_789"},
|
||||
data={"create": upsert.record, "update": upsert.record},
|
||||
data={"create": upsert.record, "update": {"vector_stores": ["recreated_store"]}},
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1056,8 +1056,12 @@ async def test_v2_wires_object_permission_onto_org_write(monkeypatch):
|
|||
|
||||
upsert = prisma.tx.litellm_objectpermissiontable.upsert.await_args.kwargs
|
||||
assert upsert["where"] == {"object_permission_id": "op-123"}
|
||||
assert upsert["data"]["update"]["mcp_servers"] == ["server-1"]
|
||||
assert upsert["data"]["update"]["vector_stores"] == ["vs-1"]
|
||||
assert upsert["data"]["create"] == {
|
||||
"object_permission_id": "op-123",
|
||||
"mcp_servers": ["server-1"],
|
||||
"vector_stores": ["vs-1"],
|
||||
}
|
||||
assert upsert["data"]["update"] == {"vector_stores": ["vs-1"]}
|
||||
write_data = prisma.db.litellm_organizationtable.update.await_args.kwargs["data"]
|
||||
assert write_data["object_permission_id"] == "op-123"
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import asyncio
|
||||
import json
|
||||
from collections.abc import Iterator
|
||||
from collections.abc import Iterator, Mapping
|
||||
from typing import Final, Literal
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
|
|
@ -21,6 +22,7 @@ from litellm.proxy.management_helpers.object_permission_utils import (
|
|||
_resolve_team_allowed_mcp_servers,
|
||||
set_object_permission,
|
||||
enforce_all_proxy_mcp_servers_grant_is_admin_only,
|
||||
handle_update_object_permission_common,
|
||||
prepare_object_permission_upsert,
|
||||
validate_key_mcp_servers_against_team,
|
||||
validate_key_search_tools_against_team,
|
||||
|
|
@ -1519,25 +1521,174 @@ _SHARED_ALIAS_DB_SERVERS = (
|
|||
)
|
||||
|
||||
|
||||
def _make_ambiguity_prisma(existing_tool_permissions=None):
|
||||
def _make_ambiguity_prisma(
|
||||
existing_tool_permissions: dict[str, list[str]] | None = None,
|
||||
existing_row_override: LiteLLM_ObjectPermissionTable | None = None,
|
||||
) -> MagicMock:
|
||||
"""Mock prisma client whose MCP server table holds _SHARED_ALIAS_DB_SERVERS and whose
|
||||
object permission row (if any) stores the given mcp_tool_permissions JSON string."""
|
||||
mock_prisma = MagicMock()
|
||||
mock_prisma: Final = MagicMock()
|
||||
mock_prisma.db.litellm_mcpservertable.find_many = AsyncMock(return_value=list(_SHARED_ALIAS_DB_SERVERS))
|
||||
mock_prisma.db.litellm_objectpermissiontable.create = AsyncMock(
|
||||
return_value=MagicMock(object_permission_id="perm-id")
|
||||
)
|
||||
existing_row = None
|
||||
if existing_tool_permissions is not None:
|
||||
existing_row = MagicMock()
|
||||
existing_row.model_dump.return_value = {
|
||||
"object_permission_id": "perm-id",
|
||||
"mcp_tool_permissions": json.dumps(existing_tool_permissions),
|
||||
}
|
||||
stored_row: Final = (
|
||||
MagicMock(
|
||||
model_dump=MagicMock(
|
||||
return_value={
|
||||
"object_permission_id": "perm-id",
|
||||
"mcp_tool_permissions": json.dumps(existing_tool_permissions),
|
||||
}
|
||||
)
|
||||
)
|
||||
if existing_tool_permissions is not None
|
||||
else None
|
||||
)
|
||||
existing_row: Final = existing_row_override if existing_row_override is not None else stored_row
|
||||
mock_prisma.db.litellm_objectpermissiontable.find_unique = AsyncMock(return_value=existing_row)
|
||||
return mock_prisma
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prepare_object_permission_upsert_omits_unsent_fields_on_create():
|
||||
permission: Final = LiteLLM_ObjectPermissionBase(mcp_toolsets=["toolset-id"])
|
||||
mock_prisma: Final = _make_ambiguity_prisma()
|
||||
|
||||
upsert: Final = await prepare_object_permission_upsert(
|
||||
new_object_permission=permission.model_dump(),
|
||||
existing_object_permission_id=None,
|
||||
prisma_client=mock_prisma,
|
||||
)
|
||||
|
||||
assert upsert.object_permission_id
|
||||
assert upsert.record == {
|
||||
"object_permission_id": upsert.object_permission_id,
|
||||
"mcp_toolsets": permission.mcp_toolsets,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prepare_object_permission_upsert_preserves_unsent_grants_on_update():
|
||||
existing_row: Final = LiteLLM_ObjectPermissionTable(
|
||||
object_permission_id="perm-id",
|
||||
mcp_servers=["solo-id"],
|
||||
mcp_tool_permissions={"solo-id": ["tool1"]},
|
||||
)
|
||||
permission: Final = LiteLLM_ObjectPermissionBase(mcp_toolsets=["toolset-id"])
|
||||
mock_prisma: Final = _make_ambiguity_prisma(existing_row_override=existing_row)
|
||||
|
||||
upsert: Final = await prepare_object_permission_upsert(
|
||||
new_object_permission=permission.model_dump(),
|
||||
existing_object_permission_id=existing_row.object_permission_id,
|
||||
prisma_client=mock_prisma,
|
||||
)
|
||||
|
||||
assert upsert.object_permission_id == existing_row.object_permission_id
|
||||
assert upsert.record == {
|
||||
"object_permission_id": existing_row.object_permission_id,
|
||||
"mcp_servers": existing_row.mcp_servers,
|
||||
"mcp_tool_permissions": json.dumps(existing_row.mcp_tool_permissions),
|
||||
"mcp_toolsets": permission.mcp_toolsets,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_permission_update_patch_keeps_false_and_empty_grants_without_changing_id() -> None:
|
||||
original: Final = LiteLLM_ObjectPermissionTable(
|
||||
object_permission_id="permission-id",
|
||||
mcp_servers=["existing-server"],
|
||||
mcp_tool_permissions={"solo-id": ["read"]},
|
||||
mcp_toolsets=["existing-toolset"],
|
||||
mcp_tool_search_enabled=True,
|
||||
)
|
||||
prisma_client: Final = _make_ambiguity_prisma(existing_row_override=original)
|
||||
|
||||
upsert: Final = await prepare_object_permission_upsert(
|
||||
new_object_permission={
|
||||
"object_permission_id": "other-id",
|
||||
"mcp_servers": None,
|
||||
"mcp_tool_permissions": {},
|
||||
"mcp_toolsets": [],
|
||||
"mcp_tool_search_enabled": False,
|
||||
},
|
||||
existing_object_permission_id=original.object_permission_id,
|
||||
prisma_client=prisma_client,
|
||||
)
|
||||
|
||||
assert upsert.record == {
|
||||
"object_permission_id": original.object_permission_id,
|
||||
"mcp_servers": original.mcp_servers,
|
||||
"mcp_tool_permissions": "{}",
|
||||
"mcp_toolsets": [],
|
||||
"mcp_tool_search_enabled": False,
|
||||
}
|
||||
assert upsert.update_record == {
|
||||
"mcp_tool_permissions": "{}",
|
||||
"mcp_toolsets": [],
|
||||
"mcp_tool_search_enabled": False,
|
||||
}
|
||||
|
||||
|
||||
class _InterleavedPermissionTable:
|
||||
def __init__(self, row: LiteLLM_ObjectPermissionTable) -> None:
|
||||
self.row = row
|
||||
self.read_count = 0
|
||||
self.reads_completed = asyncio.Event()
|
||||
|
||||
async def find_unique(self, *, where: Mapping[str, str]) -> LiteLLM_ObjectPermissionTable:
|
||||
snapshot: Final = self.row
|
||||
self.read_count += 1
|
||||
if self.read_count == 2:
|
||||
self.reads_completed.set()
|
||||
await self.reads_completed.wait()
|
||||
return snapshot
|
||||
|
||||
async def upsert(
|
||||
self, *, where: Mapping[str, str], data: Mapping[str, Mapping[str, object]]
|
||||
) -> LiteLLM_ObjectPermissionTable:
|
||||
self.row = LiteLLM_ObjectPermissionTable.model_validate({**self.row.model_dump(), **data["update"]})
|
||||
return self.row
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("toolsets_first", (False, True))
|
||||
async def test_partial_permission_updates_preserve_concurrently_changed_unsent_grants(toolsets_first: bool) -> None:
|
||||
original: Final = LiteLLM_ObjectPermissionTable(
|
||||
object_permission_id="permission-id",
|
||||
mcp_servers=["old-server"],
|
||||
mcp_access_groups=["existing-group"],
|
||||
mcp_toolsets=["old-toolset"],
|
||||
mcp_tool_search_enabled=False,
|
||||
skills=["existing-skill"],
|
||||
)
|
||||
table: Final = _InterleavedPermissionTable(original)
|
||||
prisma_client: Final = MagicMock()
|
||||
prisma_client.configure_mock(
|
||||
**{
|
||||
"db.litellm_objectpermissiontable.find_unique": AsyncMock(side_effect=table.find_unique),
|
||||
"db.litellm_objectpermissiontable.upsert": AsyncMock(side_effect=table.upsert),
|
||||
}
|
||||
)
|
||||
server_patch: Final = {"mcp_servers": ["new-server"]}
|
||||
toolset_patch: Final = {"mcp_toolsets": ["new-toolset"]}
|
||||
patches: Final = (toolset_patch, server_patch) if toolsets_first else (server_patch, toolset_patch)
|
||||
|
||||
await asyncio.gather(
|
||||
*(
|
||||
handle_update_object_permission_common(
|
||||
data_json={"object_permission": permission},
|
||||
existing_object_permission_id=original.object_permission_id,
|
||||
prisma_client=prisma_client,
|
||||
)
|
||||
for permission in patches
|
||||
)
|
||||
)
|
||||
|
||||
assert table.row == original.model_copy(
|
||||
update={"mcp_servers": ["new-server"], "mcp_toolsets": ["new-toolset"]}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"identifier, colliding_ids",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,42 @@
|
|||
"use client";
|
||||
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { CustomerForm } from "./CustomerForm";
|
||||
import type { CustomerFormValues } from "./customerPayload";
|
||||
import { useSaveCustomer } from "./useSaveCustomer";
|
||||
|
||||
type CreateCustomerModalProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onCreated?: (customerId: string) => void;
|
||||
};
|
||||
|
||||
function CreateCustomerFields({ onOpenChange, onCreated }: Omit<CreateCustomerModalProps, "open">) {
|
||||
const { saveCustomer, isSaving } = useSaveCustomer(null);
|
||||
const handleSubmit = async (values: CustomerFormValues) => {
|
||||
try {
|
||||
const customer = await saveCustomer(values);
|
||||
toast.success("Customer created successfully");
|
||||
onOpenChange(false);
|
||||
onCreated?.(customer.user_id);
|
||||
} catch (error) {
|
||||
toast.fromError(error);
|
||||
}
|
||||
};
|
||||
|
||||
return <CustomerForm onSubmit={handleSubmit} onCancel={() => onOpenChange(false)} isSaving={isSaving} />;
|
||||
}
|
||||
|
||||
export function CreateCustomerModal({ open, onOpenChange, onCreated }: CreateCustomerModalProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Customer</DialogTitle>
|
||||
</DialogHeader>
|
||||
<CreateCustomerFields onOpenChange={onOpenChange} onCreated={onCreated} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
type CustomerBlockConfirmationProps = {
|
||||
open: boolean;
|
||||
count: number;
|
||||
blocked: boolean;
|
||||
isPending: boolean;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
};
|
||||
|
||||
export function CustomerBlockConfirmation({
|
||||
open,
|
||||
count,
|
||||
blocked,
|
||||
isPending,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: CustomerBlockConfirmationProps) {
|
||||
const action = blocked ? "Block" : "Unblock";
|
||||
const customerLabel = count === 1 ? "this customer" : "these customers";
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (!nextOpen && !isPending) onCancel();
|
||||
}}
|
||||
>
|
||||
<DialogContent showCloseButton={!isPending}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{action} {count} customer{count === 1 ? "" : "s"}?
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{blocked
|
||||
? `Requests for ${customerLabel} will be rejected until unblocked. Existing settings and permissions are kept.`
|
||||
: `Requests for ${customerLabel} will be allowed again, subject to existing limits and permissions.`}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onCancel} disabled={isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant={blocked ? "destructive" : "default"} onClick={onConfirm} disabled={isPending}>
|
||||
{action}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,284 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { z } from "zod";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import { useBudgetOptions } from "@/app/(dashboard)/hooks/budgets/useBudgetOptions";
|
||||
import type { EndUser } from "@/app/(dashboard)/hooks/customers/useCustomers";
|
||||
import { useUserModels } from "@/app/(dashboard)/hooks/models/useModels";
|
||||
import MCPServerSelector from "@/components/mcp_server_management/MCPServerSelector";
|
||||
import MCPToolPermissions from "@/components/mcp_server_management/MCPToolPermissions";
|
||||
import { FormField } from "@/components/shared/form/FormField";
|
||||
import { MultiSelect } from "@/components/shared/MultiSelect";
|
||||
import { SearchSelect } from "@/components/shared/SearchSelect";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { FieldGroup } from "@/components/ui/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { useZodForm } from "@/lib/forms/useZodForm";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
import { toCustomerFormValues, type CustomerFormValues } from "./customerPayload";
|
||||
|
||||
const mcpSelectionShape = {
|
||||
servers: z.array(z.string()),
|
||||
accessGroups: z.array(z.string()),
|
||||
toolsets: z.array(z.string()),
|
||||
};
|
||||
|
||||
const customerShape = {
|
||||
user_id: z.string().trim().min(1, "Customer ID is required"),
|
||||
alias: z.string(),
|
||||
budget_id: z.string(),
|
||||
max_budget: z
|
||||
.string()
|
||||
.refine(
|
||||
(value) => !value.trim() || (Number.isFinite(Number(value)) && Number(value) >= 0),
|
||||
"Enter a non-negative budget",
|
||||
),
|
||||
models: z.array(z.string()),
|
||||
allowed_model_region: z.enum(["", "eu", "us"]),
|
||||
default_model: z.string(),
|
||||
mcp_servers_and_groups: z.object(mcpSelectionShape),
|
||||
mcp_tool_permissions: z.record(z.string(), z.array(z.string())),
|
||||
};
|
||||
|
||||
const customerSchema = z.object(customerShape).refine((values) => !values.budget_id || !values.max_budget.trim(), {
|
||||
message: "Select a budget or enter a max budget, not both",
|
||||
path: ["max_budget"],
|
||||
});
|
||||
|
||||
const editCustomerSchema = customerSchema.refine(
|
||||
(values) => !values.max_budget.trim() || Number(values.max_budget) > 0,
|
||||
{ message: "Enter a budget greater than zero when editing", path: ["max_budget"] },
|
||||
);
|
||||
|
||||
const regionOptions = [
|
||||
{ value: "", label: "Any" },
|
||||
{ value: "eu", label: "EU" },
|
||||
{ value: "us", label: "US" },
|
||||
];
|
||||
|
||||
const getBudgetFieldCopy = (hasCurrentBudget: boolean, isEdit: boolean) => {
|
||||
if (hasCurrentBudget) {
|
||||
return { emptyOption: "Keep current budget", description: "Select another budget to change the link" };
|
||||
}
|
||||
return {
|
||||
emptyOption: "No budget",
|
||||
description: isEdit
|
||||
? "Leaving both budget fields empty keeps the current budget"
|
||||
: "Choose a budget or enter a maximum below",
|
||||
};
|
||||
};
|
||||
|
||||
type CustomerFormProps = {
|
||||
customer?: EndUser | null;
|
||||
isEdit?: boolean;
|
||||
isSaving?: boolean;
|
||||
hasSharedBudget?: boolean;
|
||||
onSubmit: (values: CustomerFormValues, baseline: EndUser | null) => void | Promise<void>;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
export function CustomerForm({
|
||||
customer = null,
|
||||
isEdit = false,
|
||||
isSaving = false,
|
||||
hasSharedBudget,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}: CustomerFormProps) {
|
||||
const { accessToken } = useAuthorized();
|
||||
const { data: budgets = [] } = useBudgetOptions(accessToken);
|
||||
const { data: models = [] } = useUserModels();
|
||||
const [baseline] = useState(customer);
|
||||
const [initialValues] = useState(() => ({
|
||||
...toCustomerFormValues(baseline),
|
||||
...(isEdit ? { budget_id: "" } : {}),
|
||||
}));
|
||||
const form = useZodForm(isEdit ? editCustomerSchema : customerSchema, {
|
||||
defaultValues: initialValues,
|
||||
});
|
||||
const hasCurrentBudget = isEdit && Boolean(customer?.budget_id);
|
||||
const budgetFieldCopy = getBudgetFieldCopy(hasCurrentBudget, isEdit);
|
||||
const handleSubmit = form.handleSubmit(async (values) => {
|
||||
const isMaximumEdit = !values.budget_id && Boolean(values.max_budget.trim());
|
||||
if (hasCurrentBudget && hasSharedBudget !== false && isMaximumEdit) {
|
||||
form.setError("max_budget", {
|
||||
message:
|
||||
hasSharedBudget === undefined
|
||||
? "Unable to verify budget sharing. Reload customers before changing Max Budget"
|
||||
: "This customer has a shared budget. Leave Max Budget empty or choose another budget",
|
||||
});
|
||||
return;
|
||||
}
|
||||
await onSubmit(values, baseline);
|
||||
});
|
||||
const selectedBudgetId = form.watch("budget_id");
|
||||
const budgetOptions = [
|
||||
{ value: "", label: budgetFieldCopy.emptyOption },
|
||||
...(selectedBudgetId && !budgets.some((budget) => budget.budget_id === selectedBudgetId)
|
||||
? [{ value: selectedBudgetId, label: selectedBudgetId }]
|
||||
: []),
|
||||
...budgets.map((budget) => ({ value: budget.budget_id, label: budget.budget_id })),
|
||||
];
|
||||
const modelOptions = models.map((model) => ({ label: model, value: model }));
|
||||
const defaultModelEmptyLabel = isEdit && initialValues.default_model ? "Keep current default" : "None";
|
||||
const defaultModelOptions = [{ value: "", label: defaultModelEmptyLabel }, ...modelOptions];
|
||||
const selection = form.watch("mcp_servers_and_groups");
|
||||
|
||||
return (
|
||||
<form className="max-w-2xl" onSubmit={handleSubmit}>
|
||||
<FieldGroup>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="user_id"
|
||||
label="Customer ID"
|
||||
description="The user field or a mapped customer header on API requests identifies this customer"
|
||||
>
|
||||
{({ ref, ...control }) => <Input {...control} ref={ref} disabled={isEdit} />}
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="alias"
|
||||
label="Alias"
|
||||
description={isEdit ? "Leave empty to clear the alias" : undefined}
|
||||
>
|
||||
{({ ref, ...control }) => <Input {...control} ref={ref} />}
|
||||
</FormField>
|
||||
|
||||
<FormField control={form.control} name="budget_id" label="Budget" description={budgetFieldCopy.description}>
|
||||
{({ id, value, onChange }) => (
|
||||
<Select items={budgetOptions} value={value} onValueChange={(selected) => onChange(selected ?? "")}>
|
||||
<SelectTrigger id={id} className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{budgetOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="max_budget"
|
||||
label="Max Budget (USD)"
|
||||
description={
|
||||
isEdit
|
||||
? "Leave empty to keep the maximum. Budgets shared with other resources cannot be changed here. Updates require more than zero"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{({ ref, ...control }) => (
|
||||
<Input
|
||||
{...control}
|
||||
ref={ref}
|
||||
type="number"
|
||||
min={0}
|
||||
step="any"
|
||||
placeholder={isEdit ? "Keep current maximum" : "Unlimited"}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="models"
|
||||
label="Allowed Models"
|
||||
description="An empty selection allows all models the key can use"
|
||||
>
|
||||
{({ id, value, onChange }) => (
|
||||
<MultiSelect
|
||||
id={id}
|
||||
options={modelOptions}
|
||||
value={value}
|
||||
onValueChange={onChange}
|
||||
placeholder="Select models"
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="allowed_model_region"
|
||||
label="Allowed Model Region"
|
||||
description={isEdit ? "Any keeps the current region when editing" : undefined}
|
||||
>
|
||||
{({ id, value, onChange }) => (
|
||||
<Select items={regionOptions} value={value} onValueChange={(selected) => onChange(selected ?? "")}>
|
||||
<SelectTrigger id={id} className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{regionOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="default_model"
|
||||
label="Default Model"
|
||||
description={isEdit ? "The current default model cannot be cleared here" : undefined}
|
||||
>
|
||||
{({ id, value, onChange }) => (
|
||||
<SearchSelect
|
||||
inputId={id}
|
||||
options={defaultModelOptions}
|
||||
value={value}
|
||||
onValueChange={(selected) => onChange(selected ?? "")}
|
||||
placeholder={defaultModelEmptyLabel}
|
||||
emptyText="No models found"
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
|
||||
<FormField control={form.control} name="mcp_servers_and_groups" label="MCP Servers / Access Groups / Toolsets">
|
||||
{({ id, value, onChange }) => (
|
||||
<MCPServerSelector
|
||||
id={id}
|
||||
accessToken={accessToken ?? ""}
|
||||
allowNoMcpServers
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder="Select MCP servers, access groups or toolsets"
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
<MCPToolPermissions
|
||||
preserveUnrestrictedServers={isEdit ? initialValues.mcp_servers_and_groups.servers : undefined}
|
||||
accessToken={accessToken ?? ""}
|
||||
selectedServers={selection.servers}
|
||||
selectedAccessGroups={selection.accessGroups}
|
||||
selectedToolsets={selection.toolsets}
|
||||
toolPermissions={form.watch("mcp_tool_permissions")}
|
||||
onChange={(permissions) => form.setValue("mcp_tool_permissions", permissions)}
|
||||
/>
|
||||
</FieldGroup>
|
||||
<div
|
||||
className={cn("mt-6", {
|
||||
"sticky z-chrome -inset-x-6 -bottom-6 border-t border-border bg-card p-4": isEdit,
|
||||
})}
|
||||
>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSaving}>
|
||||
{isEdit ? "Save Changes" : "Create Customer"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,289 @@
|
|||
"use client";
|
||||
|
||||
import { ArrowLeft, MoreVertical, Pencil, Trash2 } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import type { EndUser } from "@/app/(dashboard)/hooks/customers/useCustomers";
|
||||
import { getBudgetDurationLabel } from "@/components/common_components/budget_duration_dropdown";
|
||||
import MCPServerPermissions from "@/components/permissions/MCPServerPermissions";
|
||||
import CopyButton from "@/components/shared/CopyButton";
|
||||
import { PageHeader, PageHeaderTitle } from "@/components/shared/PageHeader";
|
||||
import { ModelsCell, MoneyCell } from "@/components/shared/table_cells";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { toast } from "@/lib/toast";
|
||||
|
||||
import { CustomerForm } from "./CustomerForm";
|
||||
import { CustomerBlockAction, CustomerBlockedBadge } from "./CustomersTableColumns";
|
||||
import type { CustomerFormValues } from "./customerPayload";
|
||||
import { useSaveCustomer } from "./useSaveCustomer";
|
||||
|
||||
type CustomerTab = "overview" | "settings";
|
||||
|
||||
type CustomerInfoViewProps = {
|
||||
customer: EndUser;
|
||||
accessToken: string | null;
|
||||
canModify: boolean;
|
||||
premiumUser: boolean;
|
||||
isEdit: boolean;
|
||||
tab: CustomerTab;
|
||||
isBlocking: boolean;
|
||||
hasSharedBudget: boolean | undefined;
|
||||
onTabChange: (tab: CustomerTab) => void;
|
||||
onBack: () => void;
|
||||
onEdit: () => void;
|
||||
onCancelEdit: () => void;
|
||||
onDelete: (customer: EndUser) => void;
|
||||
onSetBlocked: (customer: EndUser) => void;
|
||||
};
|
||||
|
||||
function OverviewField({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="min-w-0 space-y-1">
|
||||
<dt className="text-sm text-muted-foreground">{label}</dt>
|
||||
<dd className="break-words text-sm">{children}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CustomerModelFields({ customer }: { customer: EndUser }) {
|
||||
return (
|
||||
<>
|
||||
<OverviewField label="Models">
|
||||
<ModelsCell models={customer.models} unrestrictedLabel="All models the key can use" />
|
||||
</OverviewField>
|
||||
<OverviewField label="Allowed Model Region">
|
||||
{customer.allowed_model_region?.toUpperCase() ?? "Any"}
|
||||
</OverviewField>
|
||||
<OverviewField label="Default Model">{customer.default_model ?? "None"}</OverviewField>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CustomerOverview({ customer, accessToken }: Pick<CustomerInfoViewProps, "customer" | "accessToken">) {
|
||||
const budget = customer.litellm_budget_table;
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<Card>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">Budget Status</p>
|
||||
<div className="mt-2 space-y-2 text-sm text-foreground">
|
||||
<div className="text-xl font-semibold [&>span]:text-left">
|
||||
<MoneyCell value={customer.spend} decimals={2} showZero />
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
of{" "}
|
||||
<span className="[&>span]:text-left">
|
||||
<MoneyCell value={budget?.max_budget} decimals={2} emptyText="Unlimited" showZero />
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-muted-foreground">Reset: {getBudgetDurationLabel(budget?.budget_duration)}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">Rate Limits</p>
|
||||
<dl className="mt-2 space-y-3">
|
||||
<OverviewField label="TPM">{budget?.tpm_limit ?? "Unlimited"}</OverviewField>
|
||||
<OverviewField label="RPM">{budget?.rpm_limit ?? "Unlimited"}</OverviewField>
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">Models</p>
|
||||
<dl className="mt-2 space-y-3">
|
||||
<CustomerModelFields customer={customer} />
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<CustomerMcpAccess customer={customer} accessToken={accessToken} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CustomerMcpAccess({ customer, accessToken }: Pick<CustomerInfoViewProps, "customer" | "accessToken">) {
|
||||
const permission = customer.object_permission;
|
||||
return (
|
||||
<MCPServerPermissions
|
||||
mcpServers={permission?.mcp_servers ?? []}
|
||||
mcpAccessGroups={permission?.mcp_access_groups ?? []}
|
||||
mcpToolPermissions={permission?.mcp_tool_permissions ?? {}}
|
||||
mcpToolsets={permission?.mcp_toolsets ?? []}
|
||||
entityType="customer"
|
||||
accessToken={accessToken}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CustomerSettings({ customer, accessToken }: Pick<CustomerInfoViewProps, "customer" | "accessToken">) {
|
||||
const budget = customer.litellm_budget_table;
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<dl className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<OverviewField label="Alias">{customer.alias || "Not set"}</OverviewField>
|
||||
<OverviewField label="Budget ID">{customer.budget_id ?? "No budget"}</OverviewField>
|
||||
<OverviewField label="Max Budget">
|
||||
<span className="[&>span]:text-left">
|
||||
<MoneyCell value={budget?.max_budget} decimals={2} emptyText="Unlimited" showZero />
|
||||
</span>
|
||||
</OverviewField>
|
||||
<OverviewField label="Reset Period">{getBudgetDurationLabel(budget?.budget_duration)}</OverviewField>
|
||||
<OverviewField label="TPM">{budget?.tpm_limit ?? "Unlimited"}</OverviewField>
|
||||
<OverviewField label="RPM">{budget?.rpm_limit ?? "Unlimited"}</OverviewField>
|
||||
<CustomerModelFields customer={customer} />
|
||||
</dl>
|
||||
<div className="border-t pt-4">
|
||||
<CustomerMcpAccess customer={customer} accessToken={accessToken} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CustomerActions({
|
||||
customer,
|
||||
premiumUser,
|
||||
isBlocking,
|
||||
onDelete,
|
||||
onSetBlocked,
|
||||
}: Pick<CustomerInfoViewProps, "customer" | "premiumUser" | "isBlocking" | "onDelete" | "onSetBlocked">) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger render={<Button variant="outline" size="icon" aria-label="More customer actions" />}>
|
||||
<MoreVertical className="size-3.5" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-auto">
|
||||
<CustomerBlockAction
|
||||
customer={customer}
|
||||
premiumUser={premiumUser}
|
||||
isBlocking={isBlocking}
|
||||
onSetBlocked={onSetBlocked}
|
||||
/>
|
||||
<DropdownMenuItem variant="destructive" onClick={() => onDelete(customer)}>
|
||||
<Trash2 className="size-3.5" />
|
||||
Delete Customer
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CustomerInfoView({
|
||||
customer,
|
||||
accessToken,
|
||||
canModify,
|
||||
hasSharedBudget,
|
||||
isEdit,
|
||||
tab,
|
||||
onTabChange,
|
||||
onBack,
|
||||
onEdit,
|
||||
onCancelEdit,
|
||||
...actions
|
||||
}: CustomerInfoViewProps) {
|
||||
const { saveCustomer, isSaving } = useSaveCustomer(customer);
|
||||
const handleSave = async (values: CustomerFormValues, baseline: EndUser | null) => {
|
||||
try {
|
||||
await saveCustomer(values, baseline);
|
||||
toast.success("Customer updated");
|
||||
onCancelEdit();
|
||||
} catch (error) {
|
||||
toast.fromError(error);
|
||||
}
|
||||
};
|
||||
const showEdit = canModify && isEdit;
|
||||
const hasDistinctAlias = Boolean(customer.alias && customer.alias !== customer.user_id);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader>
|
||||
<Button variant="ghost" className="mb-4" onClick={onBack}>
|
||||
<ArrowLeft />
|
||||
Back to Customers
|
||||
</Button>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<PageHeaderTitle className="min-w-0 break-words [&_svg]:size-4">
|
||||
{customer.alias || customer.user_id}
|
||||
<CopyButton
|
||||
value={customer.alias || customer.user_id}
|
||||
label={hasDistinctAlias ? "Copy Customer Alias" : "Copy Customer ID"}
|
||||
iconClassName="size-4"
|
||||
/>
|
||||
</PageHeaderTitle>
|
||||
{customer.blocked && <CustomerBlockedBadge />}
|
||||
</div>
|
||||
{hasDistinctAlias && (
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
<span className="min-w-0 break-words text-muted-foreground">Customer ID: {customer.user_id}</span>
|
||||
<CopyButton value={customer.user_id} label="Copy Customer ID" iconClassName="size-3.5" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{canModify && !showEdit && <CustomerActions {...actions} customer={customer} />}
|
||||
</div>
|
||||
</PageHeader>
|
||||
<Tabs
|
||||
value={showEdit ? "settings" : tab}
|
||||
onValueChange={(value) => {
|
||||
if (value === "overview" || value === "settings") onTabChange(value);
|
||||
}}
|
||||
>
|
||||
<TabsList variant="line" className="h-auto w-full justify-start rounded-none border-b p-0">
|
||||
<TabsTrigger value="overview" className="flex-none rounded-none px-4 py-2">
|
||||
Overview
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="settings" className="flex-none rounded-none px-4 py-2">
|
||||
Settings
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="overview" className="pt-4">
|
||||
<CustomerOverview customer={customer} accessToken={accessToken} />
|
||||
</TabsContent>
|
||||
<TabsContent value="settings" className="pt-4">
|
||||
<Card className="max-h-[65vh] overflow-y-auto">
|
||||
<CardContent>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="text-lg font-medium">Customer Settings</h3>
|
||||
{canModify && !showEdit && (
|
||||
<Button variant="outline" onClick={onEdit}>
|
||||
<Pencil />
|
||||
Edit Settings
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{showEdit ? (
|
||||
<CustomerForm
|
||||
key={customer.user_id}
|
||||
customer={customer}
|
||||
isEdit
|
||||
isSaving={isSaving}
|
||||
hasSharedBudget={hasSharedBudget}
|
||||
onSubmit={handleSave}
|
||||
onCancel={onCancelEdit}
|
||||
/>
|
||||
) : (
|
||||
<CustomerSettings customer={customer} accessToken={accessToken} />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,889 @@
|
|||
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { Toaster, toast as sonner } from "sonner";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ApiError } from "@/lib/http/client";
|
||||
|
||||
import type { EndUser } from "@/app/(dashboard)/hooks/customers/useCustomers";
|
||||
import { chooseSelectOption, renderWithProviders, testQueryClient } from "../../../../../tests/test-utils";
|
||||
import CustomersPanel from "./CustomersPanel";
|
||||
|
||||
const { post, get, useQuery, fetchServers, listTools, refetch, authorization } = vi.hoisted(() => ({
|
||||
post: vi.fn(),
|
||||
get: vi.fn(),
|
||||
useQuery: vi.fn(),
|
||||
fetchServers: vi.fn(),
|
||||
listTools: vi.fn(),
|
||||
refetch: vi.fn(),
|
||||
authorization: { accessToken: "sk-test", userRole: "Admin", userId: "admin", isViewOnly: false, premiumUser: false },
|
||||
}));
|
||||
|
||||
vi.unmock("@/lib/toast");
|
||||
|
||||
vi.mock("@/lib/http/api", () => ({ $api: { useQuery } }));
|
||||
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ default: () => authorization }));
|
||||
vi.mock("@/components/networking", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/components/networking")>()),
|
||||
apiClient: { post, get },
|
||||
fetchMCPServers: fetchServers,
|
||||
listMCPTools: listTools,
|
||||
fetchMCPToolsets: vi.fn().mockResolvedValue([]),
|
||||
fetchMCPAccessGroups: vi.fn().mockResolvedValue([]),
|
||||
modelAvailableCall: vi.fn().mockResolvedValue({ data: [] }),
|
||||
}));
|
||||
|
||||
const activeCustomer: EndUser = {
|
||||
user_id: "alice@example.test",
|
||||
alias: "Alice",
|
||||
blocked: false,
|
||||
spend: 12.5,
|
||||
models: [],
|
||||
object_permission: {
|
||||
object_permission_id: "permission-alice",
|
||||
mcp_servers: [],
|
||||
mcp_access_groups: ["research"],
|
||||
mcp_toolsets: ["toolset-1"],
|
||||
mcp_tool_permissions: {},
|
||||
agents: [],
|
||||
agent_access_groups: [],
|
||||
models: [],
|
||||
search_tools: [],
|
||||
vector_stores: [],
|
||||
blocked_tools: [],
|
||||
},
|
||||
};
|
||||
|
||||
const blockedCustomer: EndUser = {
|
||||
user_id: "bob@example.test",
|
||||
alias: "Bob",
|
||||
blocked: true,
|
||||
spend: 0,
|
||||
models: [],
|
||||
};
|
||||
|
||||
const customers = [activeCustomer, blockedCustomer];
|
||||
|
||||
const confirmBlocking = async (user: ReturnType<typeof userEvent.setup>, action: string, count: number) => {
|
||||
const dialog = await screen.findByRole("dialog", { name: `${action} ${count} customer${count === 1 ? "" : "s"}?` });
|
||||
expect(post).not.toHaveBeenCalled();
|
||||
await user.click(within(dialog).getByRole("button", { name: action }));
|
||||
};
|
||||
|
||||
describe("Customers management", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
sonner.dismiss();
|
||||
testQueryClient.clear();
|
||||
authorization.userRole = "Admin";
|
||||
authorization.isViewOnly = false;
|
||||
authorization.premiumUser = false;
|
||||
get.mockResolvedValue([]);
|
||||
post.mockResolvedValue(activeCustomer);
|
||||
fetchServers.mockResolvedValue([]);
|
||||
listTools.mockResolvedValue({ tools: [], error: false });
|
||||
useQuery.mockImplementation(
|
||||
(_method: string, path: string, init: { params?: { query?: { end_user_id?: string } } }) => ({
|
||||
data:
|
||||
path === "/customer/list"
|
||||
? customers
|
||||
: customers.find((customer) => customer.user_id === init.params?.query?.end_user_id),
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
isFetching: false,
|
||||
refetch,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("refreshes the customer list", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<CustomersPanel />);
|
||||
await user.click(screen.getByRole("button", { name: "Refresh" }));
|
||||
expect(refetch).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("blocks selected customers in one batch and clears selection after success", async () => {
|
||||
authorization.premiumUser = true;
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<CustomersPanel />);
|
||||
expect(screen.queryByRole("button", { name: "Block selected" })).not.toBeInTheDocument();
|
||||
await user.click(screen.getByRole("checkbox", { name: "Select all rows" }));
|
||||
expect(screen.getByText("2 selected")).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Block selected" }));
|
||||
await confirmBlocking(user, "Block", 2);
|
||||
await waitFor(() =>
|
||||
expect(post).toHaveBeenCalledExactlyOnceWith("/customer/block", {
|
||||
accessToken: "sk-test",
|
||||
body: { user_ids: customers.map((customer) => customer.user_id) },
|
||||
}),
|
||||
);
|
||||
await waitFor(() => expect(screen.queryByRole("button", { name: "Block selected" })).not.toBeInTheDocument());
|
||||
expect(screen.getByRole("checkbox", { name: `Select customer ${activeCustomer.user_id}` })).not.toBeChecked();
|
||||
});
|
||||
|
||||
it.each(["Block", "Unblock"])("gates bulk %s with the Enterprise explanation", async (action) => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<CustomersPanel />);
|
||||
await user.click(screen.getByRole("checkbox", { name: "Select all rows" }));
|
||||
const button = screen.getByRole("button", { name: `${action} selected` });
|
||||
expect(button).toHaveAttribute("aria-disabled", "true");
|
||||
await user.tab({ shift: true });
|
||||
await user.tab({ shift: true });
|
||||
if (action === "Block") await user.tab({ shift: true });
|
||||
expect(button).toHaveFocus();
|
||||
expect(await screen.findByRole("tooltip")).toHaveTextContent(
|
||||
"This is a LiteLLM Enterprise feature, and requires a valid key to use.",
|
||||
);
|
||||
expect(button).toHaveAccessibleDescription(
|
||||
"This is a LiteLLM Enterprise feature, and requires a valid key to use.",
|
||||
);
|
||||
await user.keyboard("{Enter}");
|
||||
expect(post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("unblocks only selected IDs in one batch with Enterprise", async () => {
|
||||
authorization.premiumUser = true;
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<CustomersPanel />);
|
||||
await user.click(screen.getByRole("checkbox", { name: `Select customer ${blockedCustomer.user_id}` }));
|
||||
await user.click(screen.getByRole("button", { name: "Unblock selected" }));
|
||||
await confirmBlocking(user, "Unblock", 1);
|
||||
await waitFor(() =>
|
||||
expect(post).toHaveBeenCalledExactlyOnceWith("/customer/unblock", {
|
||||
accessToken: "sk-test",
|
||||
body: { user_ids: [blockedCustomer.user_id] },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps selection and blocked state when bulk Unblock has no configured callback", async () => {
|
||||
authorization.premiumUser = true;
|
||||
const user = userEvent.setup();
|
||||
const message = "Blocked user check was never set";
|
||||
post.mockRejectedValue(new ApiError(message, 400, { detail: { error: message } }));
|
||||
renderWithProviders(
|
||||
<>
|
||||
<CustomersPanel />
|
||||
<Toaster />
|
||||
</>,
|
||||
);
|
||||
await user.click(screen.getByRole("checkbox", { name: `Select customer ${blockedCustomer.user_id}` }));
|
||||
await user.click(screen.getByRole("button", { name: "Unblock selected" }));
|
||||
await confirmBlocking(user, "Unblock", 1);
|
||||
expect(await screen.findByText(message)).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
expect(screen.getByRole("checkbox", { name: `Select customer ${blockedCustomer.user_id}` })).toBeChecked();
|
||||
expect(within(screen.getByRole("row", { name: /bob@example.test/ })).getByText("Blocked")).toBeInTheDocument();
|
||||
expect(screen.queryByText("1 customer unblocked")).not.toBeInTheDocument();
|
||||
expect(post).toHaveBeenCalledExactlyOnceWith("/customer/unblock", {
|
||||
accessToken: "sk-test",
|
||||
body: { user_ids: [blockedCustomer.user_id] },
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the shared money display for zero and small spend", () => {
|
||||
useQuery.mockReturnValue({
|
||||
data: [{ ...activeCustomer, spend: 0.001 }, blockedCustomer],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
});
|
||||
renderWithProviders(<CustomersPanel />);
|
||||
expect(within(screen.getByRole("row", { name: /alice@example.test/ })).getByText("< $0.01")).toBeInTheDocument();
|
||||
expect(within(screen.getByRole("row", { name: /bob@example.test/ })).getByText("$0.00")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ maxBudget: 0, budgetLabel: "$0.00" },
|
||||
{ maxBudget: null, budgetLabel: "Unlimited" },
|
||||
])(
|
||||
"distinguishes a $budgetLabel budget from zero spend in the list and details",
|
||||
async ({ maxBudget, budgetLabel }) => {
|
||||
const user = userEvent.setup();
|
||||
const customer: EndUser = {
|
||||
...activeCustomer,
|
||||
spend: 0,
|
||||
litellm_budget_table: {
|
||||
max_budget: maxBudget,
|
||||
tpm_limit: 600,
|
||||
rpm_limit: 6,
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
},
|
||||
};
|
||||
useQuery.mockImplementation((_method: string, path: string) => ({
|
||||
data: path === "/customer/list" ? [customer] : customer,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
}));
|
||||
renderWithProviders(<CustomersPanel />);
|
||||
|
||||
const row = screen.getByRole("row", { name: /alice@example.test/ });
|
||||
expect(within(row).getByText(maxBudget === null ? "· Unlimited" : `of ${budgetLabel}`)).toBeInTheDocument();
|
||||
await user.click(within(row).getByRole("button", { name: /Alice.*alice@example.test/ }));
|
||||
expect(await screen.findByText("Budget Status")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("$0.00")).toHaveLength(maxBudget === 0 ? 2 : 1);
|
||||
expect(screen.getAllByText(budgetLabel)).toHaveLength(maxBudget === 0 ? 2 : 1);
|
||||
await user.click(screen.getByRole("tab", { name: "Settings" }));
|
||||
expect(await screen.findByText("Max Budget")).toBeInTheDocument();
|
||||
expect(screen.getByText(budgetLabel)).toBeInTheDocument();
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps model and MCP details optional while showing each customer status", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<CustomersPanel />);
|
||||
expect(screen.queryByRole("columnheader", { name: "MCP Access" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("columnheader", { name: "Models" })).not.toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Columns" }));
|
||||
await user.click(await screen.findByRole("menuitemcheckbox", { name: "MCP Access" }));
|
||||
await user.click(await screen.findByRole("menuitemcheckbox", { name: "Models" }));
|
||||
await user.keyboard("{Escape}");
|
||||
|
||||
const alice = screen.getByRole("row", { name: /alice@example.test/ });
|
||||
expect(within(alice).getByText("Active")).toBeInTheDocument();
|
||||
expect(within(alice).getByText("All models the key can use")).toBeInTheDocument();
|
||||
expect(within(alice).getByText("1 group, 1 toolset")).toBeInTheDocument();
|
||||
const bob = screen.getByRole("row", { name: /bob@example.test/ });
|
||||
expect(within(bob).getByText("Blocked")).toBeInTheDocument();
|
||||
expect(within(bob).getByText("No restriction")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ surface: "row", action: "Block", customer: activeCustomer },
|
||||
{ surface: "detail", action: "Block", customer: activeCustomer },
|
||||
{ surface: "row", action: "Unblock", customer: blockedCustomer },
|
||||
{ surface: "detail", action: "Unblock", customer: blockedCustomer },
|
||||
])("disables $action without Enterprise in the $surface menu", async ({ surface, action, customer }) => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
<CustomersPanel />,
|
||||
surface === "detail" ? { searchParams: `customer=${encodeURIComponent(customer.user_id)}` } : undefined,
|
||||
);
|
||||
await user.click(
|
||||
screen.getByRole("button", {
|
||||
name: surface === "detail" ? "More customer actions" : `Actions for ${customer.user_id}`,
|
||||
}),
|
||||
);
|
||||
const menuAction = await screen.findByRole("menuitem", { name: action });
|
||||
expect(menuAction).toHaveAttribute("aria-disabled", "true");
|
||||
await user.keyboard("{Home}");
|
||||
if (surface === "row") await user.keyboard("{ArrowDown}");
|
||||
expect(menuAction).toHaveFocus();
|
||||
expect(await screen.findByRole("tooltip")).toHaveTextContent(
|
||||
"This is a LiteLLM Enterprise feature, and requires a valid key to use.",
|
||||
);
|
||||
expect(menuAction).toHaveAccessibleDescription(
|
||||
"This is a LiteLLM Enterprise feature, and requires a valid key to use.",
|
||||
);
|
||||
await user.keyboard("{Enter}");
|
||||
expect(post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ surface: "row", action: "Block", endpoint: "/customer/block", customer: activeCustomer },
|
||||
{ surface: "detail", action: "Block", endpoint: "/customer/block", customer: activeCustomer },
|
||||
{ surface: "row", action: "Unblock", endpoint: "/customer/unblock", customer: blockedCustomer },
|
||||
{ surface: "detail", action: "Unblock", endpoint: "/customer/unblock", customer: blockedCustomer },
|
||||
])("uses $endpoint with Enterprise in the $surface menu", async ({ surface, action, endpoint, customer }) => {
|
||||
authorization.premiumUser = true;
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
<CustomersPanel />,
|
||||
surface === "detail" ? { searchParams: `customer=${encodeURIComponent(customer.user_id)}` } : undefined,
|
||||
);
|
||||
expect(screen.queryByRole("button", { name: action })).not.toBeInTheDocument();
|
||||
await user.click(
|
||||
screen.getByRole("button", {
|
||||
name: surface === "detail" ? "More customer actions" : `Actions for ${customer.user_id}`,
|
||||
}),
|
||||
);
|
||||
const menuAction = await screen.findByRole("menuitem", { name: action });
|
||||
expect(menuAction).not.toHaveAttribute("aria-disabled", "true");
|
||||
await user.click(menuAction);
|
||||
await confirmBlocking(user, action, 1);
|
||||
await waitFor(() =>
|
||||
expect(post).toHaveBeenCalledExactlyOnceWith(endpoint, {
|
||||
accessToken: "sk-test",
|
||||
body: { user_ids: [customer.user_id] },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it.each(["row", "detail", "bulk"])("allows cancelling a block from the %s surface", async (surface) => {
|
||||
authorization.premiumUser = true;
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
<CustomersPanel />,
|
||||
surface === "detail" ? { searchParams: "customer=alice%40example.test" } : undefined,
|
||||
);
|
||||
if (surface === "bulk") {
|
||||
await user.click(screen.getByRole("checkbox", { name: "Select all rows" }));
|
||||
await user.click(screen.getByRole("button", { name: "Block selected" }));
|
||||
} else {
|
||||
await user.click(
|
||||
screen.getByRole("button", {
|
||||
name: surface === "detail" ? "More customer actions" : `Actions for ${activeCustomer.user_id}`,
|
||||
}),
|
||||
);
|
||||
await user.click(await screen.findByRole("menuitem", { name: "Block" }));
|
||||
}
|
||||
const count = surface === "bulk" ? 2 : 1;
|
||||
const dialog = await screen.findByRole("dialog", { name: `Block ${count} customer${count === 1 ? "" : "s"}?` });
|
||||
await user.click(within(dialog).getByRole("button", { name: "Cancel" }));
|
||||
expect(post).not.toHaveBeenCalled();
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([null, activeCustomer.user_id])("shows the customer ID once when alias is %s", (alias) => {
|
||||
useQuery.mockImplementation((_method: string, path: string) => ({
|
||||
data: path === "/customer/list" ? [{ ...activeCustomer, alias }] : { ...activeCustomer, alias },
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
}));
|
||||
renderWithProviders(<CustomersPanel />, { searchParams: "customer=alice%40example.test" });
|
||||
expect(screen.getAllByText(activeCustomer.user_id)).toHaveLength(1);
|
||||
expect(screen.getByRole("heading", { name: /alice@example.test/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Copy Customer ID" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each(["Alice", "alice@example.test"])("filters by ID or alias using %s", async (search) => {
|
||||
renderWithProviders(<CustomersPanel />);
|
||||
fireEvent.change(screen.getByRole("textbox", { name: "Search customers" }), { target: { value: search } });
|
||||
|
||||
await waitFor(() => expect(screen.queryByRole("row", { name: /bob@example.test/ })).not.toBeInTheDocument());
|
||||
expect(screen.getByRole("row", { name: /alice@example.test/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("creates a customer with the filled form values", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<CustomersPanel />);
|
||||
await user.click(screen.getByRole("button", { name: "Create Customer" }));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
fireEvent.change(within(dialog).getByLabelText("Customer ID"), { target: { value: " new@example.test " } });
|
||||
fireEvent.change(within(dialog).getByLabelText("Alias"), { target: { value: "New Customer" } });
|
||||
fireEvent.change(within(dialog).getByLabelText("Max Budget (USD)"), { target: { value: "25" } });
|
||||
await user.click(within(dialog).getByRole("button", { name: "Create Customer" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(post).toHaveBeenCalledExactlyOnceWith("/customer/new", {
|
||||
accessToken: "sk-test",
|
||||
body: { user_id: "new@example.test", alias: "New Customer", max_budget: 25 },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("requires a customer ID before creating", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<CustomersPanel />);
|
||||
await user.click(screen.getByRole("button", { name: "Create Customer" }));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
await user.click(within(dialog).getByRole("button", { name: "Create Customer" }));
|
||||
|
||||
expect(await within(dialog).findByText("Customer ID is required")).toBeInTheDocument();
|
||||
expect(post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("edits a deep-linked customer without rewriting MCP permissions", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onUrlUpdate = vi.fn();
|
||||
renderWithProviders(<CustomersPanel />, { searchParams: "customer=alice%40example.test", onUrlUpdate });
|
||||
expect(screen.getByRole("heading", { name: /^Alice/ })).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("tab", { name: "Settings" }));
|
||||
await user.click(await screen.findByRole("button", { name: "Edit Settings" }));
|
||||
const alias = await screen.findByLabelText("Alias");
|
||||
expect(screen.getByLabelText("Customer ID")).toBeDisabled();
|
||||
fireEvent.change(alias, { target: { value: "Updated Alice" } });
|
||||
await user.click(screen.getByRole("button", { name: "Save Changes" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(post).toHaveBeenCalledExactlyOnceWith("/customer/update", {
|
||||
accessToken: "sk-test",
|
||||
body: { user_id: activeCustomer.user_id, alias: "Updated Alice" },
|
||||
}),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(onUrlUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
searchParams: expect.any(URLSearchParams),
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.get("edit")).toBeNull();
|
||||
});
|
||||
|
||||
it("preserves an unrestricted server when editing only the alias after tools load", async () => {
|
||||
const user = userEvent.setup();
|
||||
const directCustomer: EndUser = {
|
||||
...activeCustomer,
|
||||
object_permission: {
|
||||
...activeCustomer.object_permission!,
|
||||
mcp_servers: ["server-1"],
|
||||
mcp_access_groups: [],
|
||||
mcp_toolsets: [],
|
||||
},
|
||||
};
|
||||
useQuery.mockImplementation((_method: string, path: string) => ({
|
||||
data: path === "/customer/list" ? [directCustomer] : directCustomer,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
}));
|
||||
fetchServers.mockResolvedValue([
|
||||
{
|
||||
server_id: "server-1",
|
||||
server_name: "Research Server",
|
||||
created_at: "",
|
||||
created_by: "admin",
|
||||
updated_at: "",
|
||||
updated_by: "admin",
|
||||
},
|
||||
]);
|
||||
listTools.mockResolvedValue({
|
||||
error: false,
|
||||
tools: [
|
||||
{ name: "read_document", description: "Read a document", inputSchema: {} },
|
||||
{ name: "delete_document", description: "Delete a document", inputSchema: {} },
|
||||
],
|
||||
});
|
||||
renderWithProviders(<CustomersPanel />, { searchParams: "customer=alice%40example.test&edit=true" });
|
||||
await screen.findByRole("radio", { name: "Flat List" });
|
||||
fireEvent.change(screen.getByLabelText("Alias"), { target: { value: "Updated Alice" } });
|
||||
await user.click(screen.getByRole("button", { name: "Save Changes" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(post).toHaveBeenCalledExactlyOnceWith("/customer/update", {
|
||||
accessToken: "sk-test",
|
||||
body: { user_id: activeCustomer.user_id, alias: "Updated Alice" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("denies every MCP server to a customer through the No MCP Servers option", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<CustomersPanel />, { searchParams: "customer=alice%40example.test&edit=true" });
|
||||
await user.click(await screen.findByLabelText("MCP Servers / Access Groups / Toolsets"));
|
||||
const noServers = (await screen.findAllByRole("option")).find((option) =>
|
||||
option.textContent?.startsWith("No MCP Servers"),
|
||||
);
|
||||
await user.click(noServers!);
|
||||
await user.click(document.body);
|
||||
await user.click(screen.getByRole("button", { name: "Save Changes" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(post).toHaveBeenCalledExactlyOnceWith("/customer/update", {
|
||||
accessToken: "sk-test",
|
||||
body: {
|
||||
user_id: activeCustomer.user_id,
|
||||
object_permission: {
|
||||
mcp_servers: ["no-mcp-servers"],
|
||||
mcp_access_groups: [],
|
||||
mcp_toolsets: [],
|
||||
mcp_tool_permissions: {},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows a catalog failure when removing an MCP grant and keeps the customer unsaved", async () => {
|
||||
const user = userEvent.setup();
|
||||
const directCustomer: EndUser = {
|
||||
...activeCustomer,
|
||||
object_permission: {
|
||||
object_permission_id: "permission-alice",
|
||||
mcp_servers: ["server-1"],
|
||||
mcp_access_groups: [],
|
||||
mcp_toolsets: [],
|
||||
mcp_tool_permissions: { "server-1": ["read_document"] },
|
||||
agent_access_groups: [],
|
||||
agents: [],
|
||||
blocked_tools: [],
|
||||
models: [],
|
||||
search_tools: [],
|
||||
skills: [],
|
||||
vector_stores: [],
|
||||
},
|
||||
};
|
||||
useQuery.mockImplementation((_method: string, path: string) => ({
|
||||
data: path === "/customer/list" ? [directCustomer] : directCustomer,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
}));
|
||||
fetchServers.mockRejectedValue(new Error("Catalog unavailable"));
|
||||
renderWithProviders(
|
||||
<>
|
||||
<CustomersPanel />
|
||||
<Toaster />
|
||||
</>,
|
||||
{ searchParams: "customer=alice%40example.test&edit=true" },
|
||||
);
|
||||
|
||||
await user.click(await screen.findByRole("button", { name: "Clear all" }));
|
||||
await user.click(screen.getByRole("button", { name: "Save Changes" }));
|
||||
|
||||
expect(
|
||||
await screen.findByText("MCP catalogs must finish loading before permission changes can be saved"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Save Changes" })).toBeInTheDocument();
|
||||
expect(screen.queryByText("Customer updated")).not.toBeInTheDocument();
|
||||
expect(post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects an edit budget of zero because customer updates cannot apply it", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<CustomersPanel />, { searchParams: "customer=alice%40example.test&edit=true" });
|
||||
fireEvent.change(screen.getByLabelText("Max Budget (USD)"), { target: { value: "0" } });
|
||||
await user.click(screen.getByRole("button", { name: "Save Changes" }));
|
||||
|
||||
expect(await screen.findByText("Enter a budget greater than zero when editing")).toBeInTheDocument();
|
||||
expect(post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("confirms deletion and clears the open customer from the URL", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onUrlUpdate = vi.fn();
|
||||
renderWithProviders(<CustomersPanel />, { searchParams: "customer=alice%40example.test", onUrlUpdate });
|
||||
await user.click(screen.getByRole("button", { name: "More customer actions" }));
|
||||
await user.click(await screen.findByRole("menuitem", { name: "Delete Customer" }));
|
||||
const dialog = await screen.findByRole("dialog", { name: "Delete Customer?" });
|
||||
expect(within(dialog).getByText(/recreates the customer with no restrictions/)).toBeInTheDocument();
|
||||
const confirm = within(dialog).getByRole("button", { name: "Delete" });
|
||||
expect(confirm).toBeDisabled();
|
||||
fireEvent.change(within(dialog).getByPlaceholderText(activeCustomer.user_id), {
|
||||
target: { value: activeCustomer.user_id },
|
||||
});
|
||||
await user.click(confirm);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(post).toHaveBeenCalledExactlyOnceWith("/customer/delete", {
|
||||
accessToken: "sk-test",
|
||||
body: { user_ids: [activeCustomer.user_id] },
|
||||
}),
|
||||
);
|
||||
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalledWith(expect.objectContaining({ queryString: "" })));
|
||||
expect(screen.getByRole("heading", { name: "Customers" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps Admin Viewer read-only, including an edit deep link", async () => {
|
||||
authorization.isViewOnly = true;
|
||||
const user = userEvent.setup();
|
||||
const list = renderWithProviders(<CustomersPanel />);
|
||||
expect(screen.queryByRole("checkbox", { name: "Select all rows" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Create Customer" })).not.toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Actions for alice@example.test" }));
|
||||
expect(await screen.findByRole("menuitem", { name: "Copy ID" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("menuitem", { name: "Edit" })).not.toBeInTheDocument();
|
||||
list.unmount();
|
||||
renderWithProviders(<CustomersPanel />, { searchParams: "customer=alice%40example.test&edit=true" });
|
||||
expect(screen.getByRole("tab", { name: "Overview" })).toHaveAttribute("aria-selected", "true");
|
||||
await user.click(screen.getByRole("tab", { name: "Settings" }));
|
||||
expect(screen.queryByRole("button", { name: "Edit Settings" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Edit" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Delete" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Save Changes" })).not.toBeInTheDocument();
|
||||
expect(post).not.toHaveBeenCalled();
|
||||
});
|
||||
it("uses the shared spend/budget cell and hides model details by default", () => {
|
||||
const customer: EndUser = {
|
||||
...activeCustomer,
|
||||
budget_id: "shared-budget",
|
||||
litellm_budget_table: {
|
||||
budget_id: "shared-budget",
|
||||
max_budget: 100,
|
||||
tpm_limit: 600,
|
||||
rpm_limit: 6,
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
},
|
||||
};
|
||||
useQuery.mockReturnValue({ data: [customer], isLoading: false, isError: false });
|
||||
renderWithProviders(<CustomersPanel />);
|
||||
|
||||
expect(screen.getByText("Manage customer spend, budgets, rate limits, model and MCP access")).toBeInTheDocument();
|
||||
expect(screen.getByRole("columnheader", { name: "Spend / Budget" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("columnheader", { name: "Models" })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("columnheader", { name: "TPM / RPM Limits" })).toBeInTheDocument();
|
||||
const row = screen.getByRole("row", { name: /alice@example.test/ });
|
||||
expect(within(row).getByText("$12.50")).toBeInTheDocument();
|
||||
expect(within(row).getByText("of $100.00")).toBeInTheDocument();
|
||||
expect(within(row).getByText("TPM: 600")).toBeInTheDocument();
|
||||
expect(within(row).getByText("RPM: 6")).toBeInTheDocument();
|
||||
expect(within(row).queryByText("All models the key can use")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows Overview cards and shared permissions before entering Settings", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onUrlUpdate = vi.fn();
|
||||
const customer: EndUser = {
|
||||
...activeCustomer,
|
||||
object_permission: {
|
||||
...activeCustomer.object_permission!,
|
||||
vector_stores: ["vector-1"],
|
||||
search_tools: ["search-1"],
|
||||
skills: ["skill-1"],
|
||||
},
|
||||
allowed_model_region: "eu",
|
||||
default_model: "default-model",
|
||||
litellm_budget_table: {
|
||||
max_budget: 100,
|
||||
budget_duration: "30d",
|
||||
tpm_limit: 600,
|
||||
rpm_limit: 6,
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
},
|
||||
};
|
||||
useQuery.mockImplementation((_method: string, path: string) => ({
|
||||
data: path === "/customer/list" ? [customer] : customer,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
}));
|
||||
renderWithProviders(<CustomersPanel />, { searchParams: "customer=alice%40example.test", onUrlUpdate });
|
||||
|
||||
expect(screen.getByRole("tab", { name: "Overview" })).toHaveAttribute("aria-selected", "true");
|
||||
expect(screen.getByText("Budget Status")).toBeInTheDocument();
|
||||
expect(screen.getByText("$100.00")).toBeInTheDocument();
|
||||
expect(screen.getByText("Reset: monthly")).toBeInTheDocument();
|
||||
expect(screen.getByText("Rate Limits")).toBeInTheDocument();
|
||||
expect(screen.getByText("MCP Servers")).toBeInTheDocument();
|
||||
for (const unenforced of ["Vector Stores", "Search tools", "Skills", "vector-1", "search-1", "skill-1"]) {
|
||||
expect(screen.queryByText(unenforced)).not.toBeInTheDocument();
|
||||
}
|
||||
expect(screen.getByText("All models the key can use")).toBeInTheDocument();
|
||||
expect(screen.getByText("EU")).toBeInTheDocument();
|
||||
expect(screen.getByText("default-model")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Edit Settings" })).not.toBeInTheDocument();
|
||||
await user.click(screen.getByRole("tab", { name: "Settings" }));
|
||||
expect(await screen.findByRole("button", { name: "Edit Settings" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Save Changes" })).not.toBeInTheDocument();
|
||||
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled());
|
||||
expect(onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.get("customer_tab")).toBe("settings");
|
||||
expect(onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.get("customer")).toBe(activeCustomer.user_id);
|
||||
expect(onUrlUpdate.mock.calls.at(-1)?.[0].options.history).toBe("push");
|
||||
});
|
||||
|
||||
it("opens a Settings deep link without enabling the edit form", () => {
|
||||
renderWithProviders(<CustomersPanel />, { searchParams: "customer=alice%40example.test&customer_tab=settings" });
|
||||
expect(screen.getByRole("tab", { name: "Settings" })).toHaveAttribute("aria-selected", "true");
|
||||
expect(screen.getByRole("button", { name: "Edit Settings" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Save Changes" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("updates a customer's own maximum without replacing its budget link", async () => {
|
||||
const user = userEvent.setup();
|
||||
const budget = { budget_id: "own-budget", max_budget: 100, created_at: "2026-01-01T00:00:00Z" };
|
||||
const customer: EndUser = { ...activeCustomer, budget_id: budget.budget_id, litellm_budget_table: budget };
|
||||
get.mockResolvedValue([budget]);
|
||||
useQuery.mockImplementation((_method: string, path: string) => ({
|
||||
data: path === "/customer/list" ? [customer, blockedCustomer] : customer,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
}));
|
||||
renderWithProviders(<CustomersPanel />, { searchParams: "customer=alice%40example.test&edit=true" });
|
||||
expect(screen.getByLabelText("Budget")).toHaveTextContent("Keep current budget");
|
||||
fireEvent.change(screen.getByLabelText("Max Budget (USD)"), { target: { value: "25" } });
|
||||
await user.click(screen.getByRole("button", { name: "Save Changes" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(post).toHaveBeenCalledExactlyOnceWith("/customer/update", {
|
||||
accessToken: "sk-test",
|
||||
body: { user_id: customer.user_id, max_budget: 25 },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it.each(["loading", "error"])("keeps maximum edits guarded while the customer list is %s", async (status) => {
|
||||
const user = userEvent.setup();
|
||||
const budget = { budget_id: "budget-id", max_budget: 100, created_at: "2026-01-01T00:00:00Z" };
|
||||
const customer: EndUser = { ...activeCustomer, budget_id: budget.budget_id, litellm_budget_table: budget };
|
||||
useQuery.mockImplementation((_method: string, path: string) => ({
|
||||
data: path === "/customer/list" ? undefined : customer,
|
||||
isLoading: path === "/customer/list" && status === "loading",
|
||||
isError: path === "/customer/list" && status === "error",
|
||||
}));
|
||||
renderWithProviders(<CustomersPanel />, { searchParams: "customer=alice%40example.test&edit=true" });
|
||||
fireEvent.change(screen.getByLabelText("Max Budget (USD)"), { target: { value: "25" } });
|
||||
await user.click(screen.getByRole("button", { name: "Save Changes" }));
|
||||
|
||||
expect(
|
||||
await screen.findByText("Unable to verify budget sharing. Reload customers before changing Max Budget"),
|
||||
).toBeInTheDocument();
|
||||
expect(post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a max budget when keeping a shared budget link", async () => {
|
||||
const user = userEvent.setup();
|
||||
const budget = { budget_id: "shared-budget", max_budget: 100, created_at: "2026-01-01T00:00:00Z" };
|
||||
const linkedCustomers: EndUser[] = customers.map((customer) => ({
|
||||
...customer,
|
||||
budget_id: budget.budget_id,
|
||||
litellm_budget_table: budget,
|
||||
}));
|
||||
get.mockResolvedValue([budget]);
|
||||
useQuery.mockImplementation((_method: string, path: string) => ({
|
||||
data: path === "/customer/list" ? linkedCustomers : linkedCustomers[0],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
}));
|
||||
renderWithProviders(<CustomersPanel />, { searchParams: "customer=alice%40example.test&edit=true" });
|
||||
expect(screen.getByRole("tab", { name: "Settings" })).toHaveAttribute("aria-selected", "true");
|
||||
await chooseSelectOption(user, screen.getByLabelText("Budget"), "Keep current budget");
|
||||
fireEvent.change(screen.getByLabelText("Max Budget (USD)"), { target: { value: "25" } });
|
||||
await user.click(screen.getByRole("button", { name: "Save Changes" }));
|
||||
|
||||
expect(
|
||||
await screen.findByText("This customer has a shared budget. Leave Max Budget empty or choose another budget"),
|
||||
).toBeInTheDocument();
|
||||
expect(post).not.toHaveBeenCalled();
|
||||
fireEvent.change(screen.getByLabelText("Max Budget (USD)"), { target: { value: "" } });
|
||||
await user.click(screen.getByRole("button", { name: "Save Changes" }));
|
||||
await waitFor(() =>
|
||||
expect(post).toHaveBeenCalledExactlyOnceWith("/customer/update", {
|
||||
accessToken: "sk-test",
|
||||
body: { user_id: activeCustomer.user_id },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it.each(["row", "detail"])(
|
||||
"shows an Unblock callback error and keeps the customer blocked in the %s menu",
|
||||
async (surface) => {
|
||||
authorization.premiumUser = true;
|
||||
const user = userEvent.setup();
|
||||
const message = "Blocked user check was never set";
|
||||
post.mockRejectedValue(new ApiError(message, 400, { detail: { error: message } }));
|
||||
renderWithProviders(
|
||||
<>
|
||||
<CustomersPanel />
|
||||
<Toaster />
|
||||
</>,
|
||||
surface === "detail" ? { searchParams: "customer=bob%40example.test" } : undefined,
|
||||
);
|
||||
await user.click(
|
||||
screen.getByRole("button", {
|
||||
name: surface === "detail" ? "More customer actions" : `Actions for ${blockedCustomer.user_id}`,
|
||||
}),
|
||||
);
|
||||
await user.click(await screen.findByRole("menuitem", { name: "Unblock" }));
|
||||
await confirmBlocking(user, "Unblock", 1);
|
||||
|
||||
expect(await screen.findByText(message)).toBeInTheDocument();
|
||||
expect(screen.getByText("Request Error")).toBeInTheDocument();
|
||||
expect(screen.queryByText("1 customer unblocked")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Blocked")).toBeInTheDocument();
|
||||
expect(post).toHaveBeenCalledExactlyOnceWith("/customer/unblock", {
|
||||
accessToken: "sk-test",
|
||||
body: { user_ids: [blockedCustomer.user_id] },
|
||||
});
|
||||
},
|
||||
);
|
||||
it("keeps conflicting permission edits unsaved until Settings is reopened", async () => {
|
||||
const refreshed: EndUser = {
|
||||
...activeCustomer,
|
||||
object_permission: {
|
||||
...activeCustomer.object_permission!,
|
||||
mcp_servers: ["server-1"],
|
||||
mcp_access_groups: [],
|
||||
mcp_toolsets: [],
|
||||
mcp_tool_permissions: { "server-1": ["read_document"] },
|
||||
},
|
||||
};
|
||||
const current = { customer: activeCustomer };
|
||||
useQuery.mockImplementation((_method: string, path: string) => ({
|
||||
data: path === "/customer/list" ? [current.customer] : current.customer,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
}));
|
||||
const user = userEvent.setup();
|
||||
const view = renderWithProviders(
|
||||
<>
|
||||
<CustomersPanel />
|
||||
<Toaster />
|
||||
</>,
|
||||
{
|
||||
searchParams: "customer=alice%40example.test&edit=true",
|
||||
},
|
||||
);
|
||||
const selectNoServers = async () => {
|
||||
await user.click(await screen.findByLabelText("MCP Servers / Access Groups / Toolsets"));
|
||||
await user.click(await screen.findByRole("option", { name: /No MCP Servers/ }));
|
||||
await user.keyboard("{Escape}");
|
||||
};
|
||||
await selectNoServers();
|
||||
current.customer = refreshed;
|
||||
view.rerender(
|
||||
<>
|
||||
<CustomersPanel />
|
||||
<Toaster />
|
||||
</>,
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Save Changes" }));
|
||||
|
||||
expect(await screen.findByText(/Customer permissions or budget changed while editing/)).toBeInTheDocument();
|
||||
expect(post).not.toHaveBeenCalled();
|
||||
await user.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
await user.click(screen.getByRole("button", { name: "Edit Settings" }));
|
||||
await selectNoServers();
|
||||
await user.click(screen.getByRole("button", { name: "Save Changes" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(post).toHaveBeenCalledExactlyOnceWith("/customer/update", {
|
||||
accessToken: "sk-test",
|
||||
body: {
|
||||
user_id: refreshed.user_id,
|
||||
object_permission: {
|
||||
mcp_servers: ["no-mcp-servers"],
|
||||
mcp_access_groups: [],
|
||||
mcp_toolsets: [],
|
||||
mcp_tool_permissions: {},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not restore revoked permissions after an alias-only save and customer refresh", async () => {
|
||||
const before: EndUser = {
|
||||
...activeCustomer,
|
||||
models: ["old-model"],
|
||||
allowed_model_region: "us",
|
||||
default_model: "old-model",
|
||||
budget_id: "old-budget",
|
||||
object_permission: {
|
||||
...activeCustomer.object_permission!,
|
||||
mcp_servers: ["server-1"],
|
||||
mcp_access_groups: [],
|
||||
mcp_toolsets: [],
|
||||
mcp_tool_permissions: { "server-1": ["read_document"] },
|
||||
},
|
||||
};
|
||||
const after: EndUser = {
|
||||
...before,
|
||||
models: ["restricted-model"],
|
||||
allowed_model_region: "eu",
|
||||
default_model: "new-model",
|
||||
budget_id: "new-budget",
|
||||
object_permission: { ...before.object_permission!, mcp_servers: ["no-mcp-servers"], mcp_tool_permissions: {} },
|
||||
};
|
||||
let current = before;
|
||||
useQuery.mockImplementation((_method: string, path: string) => ({
|
||||
data: path === "/customer/list" ? [current] : current,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
}));
|
||||
fetchServers.mockResolvedValue([{ server_id: "server-1", server_name: "Research Server" }]);
|
||||
listTools.mockResolvedValue({
|
||||
error: false,
|
||||
tools: [{ name: "read_document", description: "Read", inputSchema: {} }],
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
const view = renderWithProviders(<CustomersPanel />, { searchParams: "customer=alice%40example.test&edit=true" });
|
||||
await screen.findByRole("radio", { name: "Flat List" });
|
||||
fireEvent.change(screen.getByLabelText("Alias"), { target: { value: "Updated Alice" } });
|
||||
current = after;
|
||||
view.rerender(<CustomersPanel />);
|
||||
await user.click(screen.getByRole("button", { name: "Save Changes" }));
|
||||
await waitFor(() => expect(post).toHaveBeenCalledOnce());
|
||||
expect(post).toHaveBeenCalledExactlyOnceWith("/customer/update", {
|
||||
accessToken: "sk-test",
|
||||
body: { user_id: before.user_id, alias: "Updated Alice" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,215 @@
|
|||
"use client";
|
||||
|
||||
import { Contact, Plus } from "lucide-react";
|
||||
import { parseAsBoolean, parseAsString, parseAsStringLiteral, useQueryStates } from "nuqs";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
|
||||
import {
|
||||
useCustomer,
|
||||
useDeleteCustomers,
|
||||
useSetCustomersBlockedState,
|
||||
} from "@/app/(dashboard)/hooks/customers/useCustomerMutations";
|
||||
import { useCustomers, type EndUser } from "@/app/(dashboard)/hooks/customers/useCustomers";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import DeleteResourceModal from "@/components/common_components/DeleteResourceModal";
|
||||
import { Alert, AlertTitle } from "@/components/shared/Alert";
|
||||
import { Page } from "@/components/shared/Page";
|
||||
import { PageHeader, PageHeaderControls, PageHeaderDescription, PageHeaderTitle } from "@/components/shared/PageHeader";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { all_admin_roles, isProxyAdminRole } from "@/utils/roles";
|
||||
|
||||
import { CustomerBlockConfirmation } from "./CustomerBlockConfirmation";
|
||||
import { CreateCustomerModal } from "./CreateCustomerModal";
|
||||
import CustomerInfoView from "./CustomerInfoView";
|
||||
import CustomersTable from "./CustomersTable";
|
||||
import { getCustomerBudgetCounts, hasSharedCustomerBudget } from "./customerPayload";
|
||||
|
||||
const CUSTOMER_VIEW_PARSERS = {
|
||||
customer: parseAsString,
|
||||
edit: parseAsBoolean.withDefault(false),
|
||||
customer_tab: parseAsStringLiteral(["overview", "settings"]).withDefault("overview"),
|
||||
};
|
||||
|
||||
function CustomersHeader({ canModify, onCreate }: { canModify: boolean; onCreate: () => void }) {
|
||||
return (
|
||||
<PageHeader>
|
||||
<PageHeaderTitle>
|
||||
<Contact />
|
||||
Customers
|
||||
</PageHeaderTitle>
|
||||
<PageHeaderDescription>Manage customer spend, budgets, rate limits, model and MCP access</PageHeaderDescription>
|
||||
{canModify && (
|
||||
<PageHeaderControls>
|
||||
<Button onClick={onCreate}>
|
||||
<Plus />
|
||||
Create Customer
|
||||
</Button>
|
||||
</PageHeaderControls>
|
||||
)}
|
||||
</PageHeader>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CustomersPanel() {
|
||||
const { accessToken, userRole, isViewOnly, premiumUser } = useAuthorized();
|
||||
const canModify = isProxyAdminRole(userRole ?? "") && !isViewOnly;
|
||||
const [{ customer: customerId, edit, customer_tab: tab }, setView] = useQueryStates(CUSTOMER_VIEW_PARSERS, {
|
||||
history: "push",
|
||||
});
|
||||
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<EndUser | null>(null);
|
||||
const [blockTarget, setBlockTarget] = useState<EndUser | null>(null);
|
||||
const customers = useCustomers();
|
||||
const customer = useCustomer(customerId);
|
||||
const budgetCounts = useMemo(() => getCustomerBudgetCounts(customers.data ?? []), [customers.data]);
|
||||
const deleteCustomers = useDeleteCustomers();
|
||||
const setBlockedState = useSetCustomersBlockedState();
|
||||
const isBlocking = setBlockedState.isPending;
|
||||
|
||||
const openCustomer = useCallback(
|
||||
(id: string) => void setView({ customer: id, edit: null, customer_tab: null }),
|
||||
[setView],
|
||||
);
|
||||
const editCustomer = useCallback(
|
||||
(id: string) => void setView({ customer: id, edit: true, customer_tab: "settings" }),
|
||||
[setView],
|
||||
);
|
||||
const closeCustomer = useCallback(() => void setView({ customer: null, edit: null, customer_tab: null }), [setView]);
|
||||
const cancelEdit = useCallback(() => void setView({ edit: null, customer_tab: "settings" }), [setView]);
|
||||
const handleDelete = async () => {
|
||||
if (!deleteTarget || !canModify) return;
|
||||
try {
|
||||
await deleteCustomers.mutateAsync([deleteTarget.user_id]);
|
||||
if (customerId === deleteTarget.user_id) closeCustomer();
|
||||
setDeleteTarget(null);
|
||||
toast.success("Customer deleted");
|
||||
} catch (error) {
|
||||
toast.fromError(error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSetBlocked = async (userIds: string[], blocked: boolean): Promise<boolean> => {
|
||||
const canSetBlocked = canModify && !isBlocking && premiumUser;
|
||||
if (!userIds.length || !canSetBlocked) return false;
|
||||
try {
|
||||
await setBlockedState.mutateAsync({ userIds, blocked });
|
||||
toast.success(
|
||||
`${userIds.length} customer${userIds.length === 1 ? "" : "s"} ${blocked ? "blocked" : "unblocked"}`,
|
||||
);
|
||||
return true;
|
||||
} catch (error) {
|
||||
toast.fromError(error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
if (!all_admin_roles.includes(userRole ?? "")) {
|
||||
return (
|
||||
<Page>
|
||||
<Alert>
|
||||
<AlertTitle>You need an admin role to view customers</AlertTitle>
|
||||
</Alert>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Page className={customerId ? "h-full overflow-y-auto" : "h-full overflow-hidden"}>
|
||||
{customerId ? (
|
||||
<CustomerDetail
|
||||
customer={customer.data}
|
||||
isLoading={customer.isLoading}
|
||||
isError={customer.isError}
|
||||
accessToken={accessToken}
|
||||
canModify={canModify}
|
||||
premiumUser={premiumUser}
|
||||
isEdit={edit}
|
||||
hasSharedBudget={
|
||||
customer.data && customers.data && !customers.isError
|
||||
? hasSharedCustomerBudget(customer.data, budgetCounts)
|
||||
: undefined
|
||||
}
|
||||
tab={tab}
|
||||
onTabChange={(value) => void setView({ customer_tab: value, edit: null })}
|
||||
isBlocking={isBlocking}
|
||||
onBack={closeCustomer}
|
||||
onEdit={() => editCustomer(customerId)}
|
||||
onCancelEdit={cancelEdit}
|
||||
onDelete={setDeleteTarget}
|
||||
onSetBlocked={setBlockTarget}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<CustomersHeader canModify={canModify} onCreate={() => setIsCreateOpen(true)} />
|
||||
<CustomersTable
|
||||
isBlocking={isBlocking}
|
||||
onBulkSetBlocked={handleSetBlocked}
|
||||
data={customers.data ?? []}
|
||||
isLoading={customers.isLoading}
|
||||
isError={customers.isError}
|
||||
onRefresh={() => void customers.refetch()}
|
||||
isRefreshing={customers.isFetching}
|
||||
canModify={canModify}
|
||||
premiumUser={premiumUser}
|
||||
onView={openCustomer}
|
||||
onEdit={editCustomer}
|
||||
onDelete={setDeleteTarget}
|
||||
onSetBlocked={setBlockTarget}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{canModify && (
|
||||
<>
|
||||
<CustomerBlockConfirmation
|
||||
open={blockTarget !== null}
|
||||
count={1}
|
||||
blocked={!blockTarget?.blocked}
|
||||
isPending={isBlocking}
|
||||
onCancel={() => setBlockTarget(null)}
|
||||
onConfirm={async () => {
|
||||
if (blockTarget && (await handleSetBlocked([blockTarget.user_id], !blockTarget.blocked))) {
|
||||
setBlockTarget(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<CreateCustomerModal open={isCreateOpen} onOpenChange={setIsCreateOpen} onCreated={openCustomer} />
|
||||
<DeleteResourceModal
|
||||
isOpen={deleteTarget !== null}
|
||||
title="Delete Customer?"
|
||||
message="A later request naming the same customer recreates the customer with no restrictions"
|
||||
resourceInformationTitle="Customer Information"
|
||||
resourceInformation={[{ label: "Customer ID", value: deleteTarget?.user_id, code: true }]}
|
||||
requiredConfirmation={deleteTarget?.user_id}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
onOk={handleDelete}
|
||||
confirmLoading={deleteCustomers.isPending}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
type CustomerDetailProps = Omit<Parameters<typeof CustomerInfoView>[0], "customer"> & {
|
||||
customer: EndUser | undefined;
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
};
|
||||
|
||||
function CustomerDetail({ customer, isLoading, isError, onBack, ...props }: CustomerDetailProps) {
|
||||
if (isLoading) return <p role="status">Loading customer</p>;
|
||||
if (isError || !customer) {
|
||||
return (
|
||||
<>
|
||||
<Button variant="outline" onClick={onBack}>
|
||||
Back to Customers
|
||||
</Button>
|
||||
<Alert variant="error">
|
||||
<AlertTitle>Unable to load customer</AlertTitle>
|
||||
</Alert>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return <CustomerInfoView key={customer.user_id} {...props} customer={customer} onBack={onBack} />;
|
||||
}
|
||||
|
|
@ -0,0 +1,222 @@
|
|||
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import Papa from "papaparse";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { toast } from "@/lib/toast";
|
||||
import type { EndUser } from "@/app/(dashboard)/hooks/customers/useCustomers";
|
||||
import { chooseSelectOption, renderWithProviders } from "../../../../../tests/test-utils";
|
||||
import CustomersTable from "./CustomersTable";
|
||||
|
||||
const customer: EndUser = {
|
||||
user_id: "customer-01",
|
||||
alias: "Research",
|
||||
blocked: false,
|
||||
spend: 0,
|
||||
models: ["model-1", "model-2"],
|
||||
litellm_budget_table: { max_budget: 0, created_at: "2026-01-01T00:00:00Z" },
|
||||
object_permission: {
|
||||
object_permission_id: "permission-1",
|
||||
mcp_servers: ["server-1"],
|
||||
mcp_access_groups: ["group-1"],
|
||||
mcp_toolsets: ["toolset-1"],
|
||||
mcp_tool_permissions: {},
|
||||
agent_access_groups: [],
|
||||
agents: [],
|
||||
models: [],
|
||||
blocked_tools: [],
|
||||
search_tools: [],
|
||||
vector_stores: [],
|
||||
},
|
||||
};
|
||||
const props = {
|
||||
data: [customer],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
isRefreshing: false,
|
||||
isBlocking: false,
|
||||
canModify: true,
|
||||
premiumUser: true,
|
||||
onRefresh: vi.fn(),
|
||||
onView: vi.fn(),
|
||||
onEdit: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
onSetBlocked: vi.fn(),
|
||||
onBulkSetBlocked: vi.fn().mockResolvedValue(true),
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("CustomersTable controls", () => {
|
||||
it("combines all three filters, writes URL state, and resets them", async () => {
|
||||
const rows: EndUser[] = [
|
||||
customer,
|
||||
{ ...customer, user_id: "customer-02", alias: "Blocked", blocked: true },
|
||||
{ ...customer, user_id: "customer-03", alias: "Other", object_permission: null, litellm_budget_table: null },
|
||||
];
|
||||
const user = userEvent.setup();
|
||||
const onUrlUpdate = vi.fn();
|
||||
renderWithProviders(<CustomersTable {...props} data={rows} />, { onUrlUpdate });
|
||||
await user.click(screen.getByRole("checkbox", { name: "Select all rows" }));
|
||||
await user.click(screen.getByRole("button", { name: "Filters" }));
|
||||
expect(screen.getByRole("combobox", { name: "Budget" })).toHaveTextContent(/^All\b/);
|
||||
await chooseSelectOption(user, screen.getByRole("combobox", { name: "Status" }), "Active");
|
||||
await chooseSelectOption(user, screen.getByRole("combobox", { name: "Budget" }), "Has budget");
|
||||
await chooseSelectOption(user, screen.getByRole("combobox", { name: "MCP access" }), "Limited MCP access");
|
||||
expect(screen.getByRole("combobox", { name: "Budget" })).toHaveTextContent(/^Has budget\b/);
|
||||
await user.click(screen.getByRole("button", { name: "Apply Filters" }));
|
||||
|
||||
await waitFor(() => expect(screen.queryByRole("row", { name: /customer-02/ })).not.toBeInTheDocument());
|
||||
expect(screen.queryByRole("row", { name: /customer-03/ })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("row", { name: /customer-01/ })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Unblock selected" })).not.toBeInTheDocument();
|
||||
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled());
|
||||
const params = onUrlUpdate.mock.calls.at(-1)?.[0].searchParams;
|
||||
expect(params.get("filter_blocked")).toBe("active");
|
||||
expect(params.get("filter_max_budget")).toBe("yes");
|
||||
expect(params.get("filter_mcp_access")).toBe("limited");
|
||||
await user.click(screen.getByRole("button", { name: /^Filters/ }));
|
||||
await user.click(screen.getByRole("button", { name: "Reset" }));
|
||||
await user.click(screen.getByRole("button", { name: "Apply Filters" }));
|
||||
expect(await screen.findByRole("row", { name: /customer-02/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole("row", { name: /customer-03/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("restores negative filters from the URL and clears selection on search changes", async () => {
|
||||
const blocked: EndUser = {
|
||||
...customer,
|
||||
user_id: "customer-02",
|
||||
blocked: true,
|
||||
object_permission: null,
|
||||
litellm_budget_table: null,
|
||||
};
|
||||
const user = userEvent.setup();
|
||||
const onUrlUpdate = vi.fn();
|
||||
renderWithProviders(<CustomersTable {...props} data={[customer, blocked]} />, {
|
||||
searchParams: "filter_blocked=blocked&filter_max_budget=no&filter_mcp_access=no",
|
||||
onUrlUpdate,
|
||||
});
|
||||
expect(screen.getByRole("row", { name: /customer-02/ })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("row", { name: /customer-01/ })).not.toBeInTheDocument();
|
||||
await user.click(screen.getByRole("checkbox", { name: "Select all rows" }));
|
||||
fireEvent.change(screen.getByRole("textbox", { name: "Search customers" }), { target: { value: "missing" } });
|
||||
await waitFor(() => expect(screen.queryByRole("row", { name: /customer-02/ })).not.toBeInTheDocument());
|
||||
expect(screen.queryByRole("button", { name: "Unblock selected" })).not.toBeInTheDocument();
|
||||
await waitFor(() =>
|
||||
expect(onUrlUpdate).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
searchParams: expect.any(URLSearchParams),
|
||||
queryString: expect.stringContaining("search=missing"),
|
||||
}),
|
||||
),
|
||||
);
|
||||
fireEvent.change(screen.getByRole("textbox", { name: "Search customers" }), { target: { value: "" } });
|
||||
expect(await screen.findByRole("checkbox", { name: "Select customer customer-02" })).not.toBeChecked();
|
||||
});
|
||||
|
||||
it("selects the current page and retains IDs selected on other pages", async () => {
|
||||
const rows = Array.from(
|
||||
{ length: 26 },
|
||||
(_, index): EndUser => ({
|
||||
...customer,
|
||||
user_id: `customer-${String(index).padStart(2, "0")}`,
|
||||
alias: null,
|
||||
}),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<CustomersTable {...props} data={rows} />, { searchParams: "page_size=25" });
|
||||
await user.click(screen.getByRole("checkbox", { name: "Select all rows" }));
|
||||
expect(screen.getByText("25 selected")).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Go to next page" }));
|
||||
expect(await screen.findByRole("checkbox", { name: "Select customer customer-25" })).not.toBeChecked();
|
||||
await user.click(screen.getByRole("checkbox", { name: "Select all rows" }));
|
||||
expect(screen.getByText("26 selected")).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Unblock selected" }));
|
||||
const dialog = await screen.findByRole("dialog", { name: "Unblock 26 customers?" });
|
||||
expect(props.onBulkSetBlocked).not.toHaveBeenCalled();
|
||||
await user.click(within(dialog).getByRole("button", { name: "Unblock" }));
|
||||
expect(props.onBulkSetBlocked).toHaveBeenCalledExactlyOnceWith(
|
||||
rows.map((row) => row.user_id),
|
||||
false,
|
||||
);
|
||||
await waitFor(() => expect(screen.queryByRole("button", { name: "Unblock selected" })).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ blocked: false, action: "Block" },
|
||||
{ blocked: true, action: "Unblock" },
|
||||
])("disables selection, row $action, and batch controls during a block mutation", async ({ blocked, action }) => {
|
||||
const data = [{ ...customer, blocked }];
|
||||
const user = userEvent.setup();
|
||||
const view = renderWithProviders(<CustomersTable {...props} data={data} />);
|
||||
await user.click(screen.getByRole("checkbox", { name: "Select all rows" }));
|
||||
view.rerender(<CustomersTable {...props} data={data} isBlocking />);
|
||||
expect(screen.getByRole("button", { name: "Block selected" })).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "Unblock selected" })).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "Clear selection" })).toBeDisabled();
|
||||
await user.click(screen.getByRole("checkbox", { name: "Select customer customer-01" }));
|
||||
expect(screen.getByRole("checkbox", { name: "Select customer customer-01" })).toBeChecked();
|
||||
await user.click(screen.getByRole("button", { name: "Actions for customer-01" }));
|
||||
expect(await screen.findByRole("menuitem", { name: action })).toHaveAttribute("aria-disabled", "true");
|
||||
expect(props.onBulkSetBlocked).not.toHaveBeenCalled();
|
||||
expect(props.onSetBlocked).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ search: "matching", count: 26 },
|
||||
{ search: "customer-00", count: 1 },
|
||||
])("exports $count matching customers using search and filters", async ({ search, count }) => {
|
||||
const rows = Array.from(
|
||||
{ length: 28 },
|
||||
(_, index): EndUser => ({
|
||||
...customer,
|
||||
user_id: `customer-${String(index).padStart(2, "0")}`,
|
||||
alias: index === 27 ? "Other" : "Matching",
|
||||
blocked: index === 26,
|
||||
}),
|
||||
);
|
||||
const createObjectURL = vi.fn().mockReturnValue("blob:customers-export");
|
||||
const revokeObjectURL = vi.fn();
|
||||
vi.stubGlobal(
|
||||
"URL",
|
||||
class extends URL {
|
||||
static createObjectURL = createObjectURL;
|
||||
static revokeObjectURL = revokeObjectURL;
|
||||
},
|
||||
);
|
||||
const download = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<CustomersTable {...props} data={rows} />, {
|
||||
searchParams: `page_size=25&search=${search}&filter_blocked=active&filter_mcp_access=yes`,
|
||||
});
|
||||
expect(screen.getByText(`Showing 1-${Math.min(count, 25)} of ${count}`)).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Export CSV" }));
|
||||
const blob = createObjectURL.mock.calls[0][0] as Blob;
|
||||
const csv = await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(String(reader.result));
|
||||
reader.onerror = () => reject(reader.error);
|
||||
reader.readAsText(blob);
|
||||
});
|
||||
const parsed = Papa.parse<Record<string, string>>(csv, { header: true });
|
||||
expect(parsed.errors).toEqual([]);
|
||||
expect(parsed.data.map((row) => row["Customer ID"])).toEqual(rows.slice(0, count).map((row) => row.user_id));
|
||||
expect(download).toHaveBeenCalledOnce();
|
||||
expect(toast.success).toHaveBeenCalledWith(`Exported ${count} customer${count === 1 ? "" : "s"}`);
|
||||
expect(revokeObjectURL).toHaveBeenCalledExactlyOnceWith("blob:customers-export");
|
||||
});
|
||||
|
||||
it("wires the Resources badges into the customer table", () => {
|
||||
renderWithProviders(<CustomersTable {...props} />);
|
||||
expect(screen.getByRole("columnheader", { name: "Resources" })).toBeInTheDocument();
|
||||
const row = screen.getByRole("row", { name: /customer-01/ });
|
||||
expect(within(row).getByLabelText("2 models")).toHaveTextContent("2");
|
||||
expect(within(row).getByLabelText("1 MCP server")).toHaveTextContent("1");
|
||||
expect(within(row).getByLabelText("1 MCP access group")).toHaveTextContent("1");
|
||||
expect(within(row).getByLabelText("1 MCP toolset")).toHaveTextContent("1");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,296 @@
|
|||
"use client";
|
||||
|
||||
import type { RowSelectionState } from "@tanstack/react-table";
|
||||
import { Ban, Contact, Download, Search, ShieldCheck } from "lucide-react";
|
||||
import { useId, useMemo, useState } from "react";
|
||||
|
||||
import type { EndUser } from "@/app/(dashboard)/hooks/customers/useCustomers";
|
||||
import {
|
||||
createSelectionColumn,
|
||||
DataTable,
|
||||
DataTableFilterDrawer,
|
||||
DataTableFilterField,
|
||||
DataTableToolbar,
|
||||
useUrlTableState,
|
||||
} from "@/components/shared/DataTable";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { toast } from "@/lib/toast";
|
||||
|
||||
import { CustomerBlockConfirmation } from "./CustomerBlockConfirmation";
|
||||
import { exportCustomersToCsv } from "./customerCsvExport";
|
||||
import {
|
||||
CUSTOMER_FILTER_FIELDS,
|
||||
customerFilterFn,
|
||||
customerMatchesSearch,
|
||||
formatCustomerFilterValue,
|
||||
} from "./customerFilters";
|
||||
import { getCustomerTableColumns, type CustomerTableActions } from "./CustomersTableColumns";
|
||||
|
||||
const TABLE_STATE_OPTIONS = {
|
||||
sortFields: ["user_id", "spend", "max_budget", "blocked"],
|
||||
defaultSort: { id: "user_id", desc: false },
|
||||
defaultPageSize: 50,
|
||||
filterColumns: CUSTOMER_FILTER_FIELDS.map((field) => field.id),
|
||||
};
|
||||
|
||||
const DEFAULT_COLUMN_VISIBILITY = { mcp_access: false, models: false, max_budget: false };
|
||||
const FILTER_LABELS = Object.fromEntries(CUSTOMER_FILTER_FIELDS.map((field) => [field.id, field.label]));
|
||||
|
||||
type CustomersTableProps = CustomerTableActions & {
|
||||
data: EndUser[];
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
onRefresh: () => void;
|
||||
isRefreshing: boolean;
|
||||
onBulkSetBlocked: (userIds: string[], blocked: boolean) => Promise<boolean>;
|
||||
};
|
||||
|
||||
function EmptyCustomers() {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-2 py-6">
|
||||
<Contact className="size-5 text-muted-foreground" />
|
||||
<p className="text-sm font-medium">No customers found</p>
|
||||
<p className="text-sm text-muted-foreground">Create a customer to manage customer limits and access</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BulkBlockedStateButton({
|
||||
blocked,
|
||||
premiumUser,
|
||||
disabled,
|
||||
onClick,
|
||||
}: {
|
||||
blocked: boolean;
|
||||
premiumUser: boolean;
|
||||
disabled: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const descriptionId = useId();
|
||||
const content = (
|
||||
<>
|
||||
{blocked ? <Ban /> : <ShieldCheck />}
|
||||
{blocked ? "Block selected" : "Unblock selected"}
|
||||
</>
|
||||
);
|
||||
if (premiumUser)
|
||||
return (
|
||||
<Button variant="outline" size="sm" disabled={disabled} onClick={onClick}>
|
||||
{content}
|
||||
</Button>
|
||||
);
|
||||
return (
|
||||
<TooltipProvider delay={300}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
aria-describedby={descriptionId}
|
||||
render={<Button variant="outline" size="sm" aria-disabled="true" className="opacity-50" />}
|
||||
>
|
||||
{content}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent id={descriptionId} role="tooltip">
|
||||
This is a LiteLLM Enterprise feature, and requires a valid key to use.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CustomersTable({
|
||||
data,
|
||||
isLoading,
|
||||
isError,
|
||||
onRefresh,
|
||||
isRefreshing,
|
||||
isBlocking,
|
||||
onBulkSetBlocked,
|
||||
...actions
|
||||
}: CustomersTableProps) {
|
||||
const {
|
||||
search,
|
||||
setSearch,
|
||||
sorting,
|
||||
onSortingChange,
|
||||
pagination,
|
||||
onPaginationChange,
|
||||
columnFilters,
|
||||
onColumnFiltersChange,
|
||||
} = useUrlTableState(TABLE_STATE_OPTIONS);
|
||||
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||
const [blockTarget, setBlockTarget] = useState<{ userIds: string[]; blocked: boolean } | null>(null);
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
||||
const filterScope = JSON.stringify([search, columnFilters]);
|
||||
const [selectionScope, setSelectionScope] = useState(filterScope);
|
||||
if (selectionScope !== filterScope) {
|
||||
setSelectionScope(filterScope);
|
||||
setRowSelection({});
|
||||
}
|
||||
const { canModify, premiumUser, onView, onEdit, onDelete, onSetBlocked } = actions;
|
||||
const columns = useMemo(() => {
|
||||
const columnActions = { canModify, premiumUser, isBlocking, onView, onEdit, onDelete, onSetBlocked };
|
||||
const customerColumns = getCustomerTableColumns(columnActions).map((column) =>
|
||||
CUSTOMER_FILTER_FIELDS.some((field) => field.id === column.id)
|
||||
? { ...column, filterFn: customerFilterFn }
|
||||
: column,
|
||||
);
|
||||
return canModify
|
||||
? [
|
||||
createSelectionColumn<EndUser>({ rowAriaLabel: (row) => `Select customer ${row.original.user_id}` }),
|
||||
...customerColumns,
|
||||
]
|
||||
: customerColumns;
|
||||
}, [canModify, premiumUser, isBlocking, onView, onEdit, onDelete, onSetBlocked]);
|
||||
const searchedCustomers = useMemo(
|
||||
() => data.filter((customer) => customerMatchesSearch(customer, search)),
|
||||
[data, search],
|
||||
);
|
||||
|
||||
const handleBulkSetBlocked = async (userIds: string[], blocked: boolean) => {
|
||||
if (await onBulkSetBlocked(userIds, blocked)) {
|
||||
setRowSelection({});
|
||||
setBlockTarget(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-4">
|
||||
<CustomerBlockConfirmation
|
||||
open={blockTarget !== null}
|
||||
count={blockTarget?.userIds.length ?? 0}
|
||||
blocked={blockTarget?.blocked ?? false}
|
||||
isPending={isBlocking}
|
||||
onCancel={() => setBlockTarget(null)}
|
||||
onConfirm={() => {
|
||||
if (blockTarget) void handleBulkSetBlocked(blockTarget.userIds, blockTarget.blocked);
|
||||
}}
|
||||
/>
|
||||
<DataTable
|
||||
fillHeight
|
||||
pageSizeOptions={[25, 50, 100]}
|
||||
data={searchedCustomers}
|
||||
columns={columns}
|
||||
getRowId={(customer) => customer.user_id}
|
||||
defaultColumnVisibility={DEFAULT_COLUMN_VISIBILITY}
|
||||
enableRowSelection={canModify && !isBlocking}
|
||||
rowSelection={rowSelection}
|
||||
onRowSelectionChange={(updater) => {
|
||||
if (!isBlocking) setRowSelection(updater);
|
||||
}}
|
||||
toolbar={(table) => {
|
||||
const selectedIds = table.getFilteredSelectedRowModel().rows.map((row) => row.original.user_id);
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
aria-label="Search customers"
|
||||
placeholder="Search customers by ID or alias"
|
||||
className="h-8 w-56 pl-8"
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<DataTableToolbar
|
||||
className="flex-1"
|
||||
table={table}
|
||||
onRefresh={onRefresh}
|
||||
isRefreshing={isRefreshing}
|
||||
onOpenFilters={() => setFiltersOpen(true)}
|
||||
filterLabels={FILTER_LABELS}
|
||||
formatFilterValue={formatCustomerFilterValue}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={isLoading || isError || table.getFilteredRowModel().rows.length === 0}
|
||||
onClick={() => {
|
||||
try {
|
||||
const count = exportCustomersToCsv(
|
||||
table.getPrePaginationRowModel().rows.map((row) => row.original),
|
||||
);
|
||||
toast.success(`Exported ${count} customer${count === 1 ? "" : "s"}`);
|
||||
} catch (error) {
|
||||
toast.fromError(error);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Download />
|
||||
Export CSV
|
||||
</Button>
|
||||
</DataTableToolbar>
|
||||
</div>
|
||||
{canModify && selectedIds.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">{selectedIds.length} selected</span>
|
||||
<BulkBlockedStateButton
|
||||
blocked
|
||||
premiumUser={premiumUser}
|
||||
disabled={isBlocking}
|
||||
onClick={() => setBlockTarget({ userIds: selectedIds, blocked: true })}
|
||||
/>
|
||||
<BulkBlockedStateButton
|
||||
blocked={false}
|
||||
premiumUser={premiumUser}
|
||||
disabled={isBlocking}
|
||||
onClick={() => setBlockTarget({ userIds: selectedIds, blocked: false })}
|
||||
/>
|
||||
<Button variant="ghost" size="sm" disabled={isBlocking} onClick={() => setRowSelection({})}>
|
||||
Clear selection
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<DataTableFilterDrawer
|
||||
table={table}
|
||||
open={filtersOpen}
|
||||
onOpenChange={setFiltersOpen}
|
||||
description="Filter by status, a budget limit and configured MCP grants"
|
||||
>
|
||||
{({ get, set }) =>
|
||||
CUSTOMER_FILTER_FIELDS.map((field) => (
|
||||
<DataTableFilterField key={field.id} label={field.label}>
|
||||
<Select
|
||||
items={[{ value: "all", label: "All" }, ...field.options]}
|
||||
value={(get(field.id) as string) || "all"}
|
||||
onValueChange={(value) => set(field.id, value === "all" ? undefined : value)}
|
||||
>
|
||||
<SelectTrigger aria-label={field.label} className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All</SelectItem>
|
||||
{field.options.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</DataTableFilterField>
|
||||
))
|
||||
}
|
||||
</DataTableFilterDrawer>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
filterMode="client"
|
||||
columnFilters={columnFilters}
|
||||
onColumnFiltersChange={onColumnFiltersChange}
|
||||
paginationMode="client"
|
||||
pagination={pagination}
|
||||
onPaginationChange={onPaginationChange}
|
||||
sortingMode="client"
|
||||
sorting={sorting}
|
||||
onSortingChange={onSortingChange}
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
loadingMessage="Loading customers"
|
||||
noDataMessage={<EmptyCustomers />}
|
||||
size="compact"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { EndUser } from "@/app/(dashboard)/hooks/customers/useCustomers";
|
||||
import { ALL_PROXY_MCP_SERVERS_SENTINEL, NO_MCP_SERVERS_SENTINEL } from "@/components/mcp_tools/constants";
|
||||
|
||||
import { CustomerResourcesCell, describeMcpGrant } from "./CustomersTableColumns";
|
||||
|
||||
const customer: EndUser = {
|
||||
user_id: "customer-id",
|
||||
spend: 0,
|
||||
blocked: false,
|
||||
models: ["model-a", "model-b"],
|
||||
object_permission: {
|
||||
object_permission_id: "permission-id",
|
||||
agent_access_groups: [],
|
||||
agents: [],
|
||||
blocked_tools: [],
|
||||
mcp_servers: ["server-a"],
|
||||
mcp_access_groups: ["group-a", "group-b"],
|
||||
mcp_toolsets: ["toolset-a", "toolset-b", "toolset-c"],
|
||||
mcp_tool_permissions: { "server-b": ["read"] },
|
||||
models: [],
|
||||
search_tools: [],
|
||||
vector_stores: [],
|
||||
},
|
||||
};
|
||||
|
||||
describe("CustomerResourcesCell", () => {
|
||||
it("shows separately labeled counts for models and each MCP grant type", () => {
|
||||
render(<CustomerResourcesCell customer={customer} />);
|
||||
expect(screen.getByLabelText("2 models")).toHaveTextContent("2");
|
||||
expect(screen.getByLabelText("2 MCP servers")).toHaveTextContent("2");
|
||||
expect(screen.getByLabelText("2 MCP access groups")).toHaveTextContent("2");
|
||||
expect(screen.getByLabelText("3 MCP toolsets")).toHaveTextContent("3");
|
||||
});
|
||||
|
||||
it("shows All for unrestricted models and excludes unsupported MCP sentinel counts", () => {
|
||||
render(
|
||||
<CustomerResourcesCell
|
||||
customer={{
|
||||
...customer,
|
||||
models: [],
|
||||
object_permission: {
|
||||
...customer.object_permission!,
|
||||
mcp_servers: [ALL_PROXY_MCP_SERVERS_SENTINEL],
|
||||
mcp_tool_permissions: {},
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByLabelText("All models")).toHaveTextContent("All");
|
||||
expect(screen.getByLabelText("0 MCP servers")).toHaveTextContent("0");
|
||||
});
|
||||
});
|
||||
|
||||
const emptyGrant: NonNullable<EndUser["object_permission"]> = {
|
||||
object_permission_id: "permission-id",
|
||||
agent_access_groups: [],
|
||||
agents: [],
|
||||
blocked_tools: [],
|
||||
mcp_servers: [],
|
||||
mcp_access_groups: [],
|
||||
mcp_toolsets: [],
|
||||
mcp_tool_permissions: {},
|
||||
models: [],
|
||||
search_tools: [],
|
||||
vector_stores: [],
|
||||
};
|
||||
|
||||
describe("describeMcpGrant", () => {
|
||||
it.each([null, undefined, emptyGrant])("describes an empty grant as unrestricted", (permission) => {
|
||||
expect(describeMcpGrant(permission)).toBe("No restriction");
|
||||
});
|
||||
|
||||
it("summarizes servers, groups, toolsets and tool limits", () => {
|
||||
const permission = {
|
||||
...emptyGrant,
|
||||
mcp_servers: ["server-a", "server-b"],
|
||||
mcp_access_groups: ["group-id"],
|
||||
mcp_toolsets: ["toolset-id"],
|
||||
mcp_tool_permissions: { "server-a": ["read"] },
|
||||
};
|
||||
expect(describeMcpGrant(permission)).toBe("2 servers, 1 group, 1 toolset, 1 tool limit");
|
||||
});
|
||||
|
||||
it("does not describe a tool-only grant as unrestricted", () => {
|
||||
const permission = { ...emptyGrant, mcp_tool_permissions: { "server-id": ["read"] } };
|
||||
expect(describeMcpGrant(permission)).toBe("1 server, 1 tool limit");
|
||||
});
|
||||
|
||||
it("does not describe the unsupported customer all-servers sentinel as granting access", () => {
|
||||
const permission = { ...emptyGrant, mcp_servers: [ALL_PROXY_MCP_SERVERS_SENTINEL] };
|
||||
expect(describeMcpGrant(permission)).toBe("No MCP access");
|
||||
});
|
||||
|
||||
it("shows the no-servers sentinel as blocked even if other grants exist", () => {
|
||||
const permission = {
|
||||
...emptyGrant,
|
||||
mcp_servers: [NO_MCP_SERVERS_SENTINEL],
|
||||
mcp_access_groups: ["group-id"],
|
||||
};
|
||||
expect(describeMcpGrant(permission)).toBe("No MCP access");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,320 @@
|
|||
"use client";
|
||||
|
||||
import { useId } from "react";
|
||||
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import { Ban, Copy, Layers, MoreHorizontal, Pencil, Server, ShieldCheck, Trash2, Network, Wrench } from "lucide-react";
|
||||
|
||||
import type { EndUser } from "@/app/(dashboard)/hooks/customers/useCustomers";
|
||||
import { ALL_PROXY_MCP_SERVERS_SENTINEL, NO_MCP_SERVERS_SENTINEL } from "@/components/mcp_tools/constants";
|
||||
import { DataTableSortHeader } from "@/components/shared/DataTable";
|
||||
import { IdentityCell, ModelsCell, SpendBudgetCell, StatusBadge } from "@/components/shared/table_cells";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
import { copyToClipboard } from "@/utils/dataUtils";
|
||||
|
||||
import { customerMcpScope, getCustomerResourceCounts } from "./customerResources";
|
||||
|
||||
export type CustomerTableActions = {
|
||||
canModify: boolean;
|
||||
premiumUser: boolean;
|
||||
isBlocking: boolean;
|
||||
onView: (customerId: string) => void;
|
||||
onEdit: (customerId: string) => void;
|
||||
onDelete: (customer: EndUser) => void;
|
||||
onSetBlocked: (customer: EndUser) => void;
|
||||
};
|
||||
|
||||
export function CustomerBlockedBadge() {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={<span className="inline-flex" />}>
|
||||
<Badge variant="destructive">
|
||||
<Ban className="size-3" />
|
||||
Blocked
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent role="tooltip">
|
||||
{"Enforced only when LiteLLM Enterprise's blocked user check is enabled"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export function CustomerBlockAction({
|
||||
customer,
|
||||
premiumUser,
|
||||
isBlocking = false,
|
||||
onSetBlocked,
|
||||
}: {
|
||||
customer: EndUser;
|
||||
premiumUser: boolean;
|
||||
isBlocking?: boolean;
|
||||
onSetBlocked: (customer: EndUser) => void;
|
||||
}) {
|
||||
const descriptionId = useId();
|
||||
const label = customer.blocked ? "Unblock" : "Block";
|
||||
const content = (
|
||||
<>
|
||||
{customer.blocked ? <ShieldCheck /> : <Ban />}
|
||||
{label}
|
||||
</>
|
||||
);
|
||||
if (premiumUser) {
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
disabled={isBlocking}
|
||||
variant={customer.blocked ? "default" : "destructive"}
|
||||
onClick={() => onSetBlocked(customer)}
|
||||
>
|
||||
{content}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<TooltipProvider delay={300}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
aria-describedby={descriptionId}
|
||||
render={<DropdownMenuItem aria-disabled="true" closeOnClick={false} className="opacity-50" />}
|
||||
>
|
||||
{content}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent id={descriptionId} role="tooltip">
|
||||
This is a LiteLLM Enterprise feature, and requires a valid key to use.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const countLabel = (count: number, noun: string): string => (count ? `${count} ${noun}${count === 1 ? "" : "s"}` : "");
|
||||
|
||||
export function describeMcpGrant(permission: EndUser["object_permission"]): string {
|
||||
const scope = customerMcpScope(permission);
|
||||
if (scope === "denied") return "No MCP access";
|
||||
if (scope === "unrestricted") return "No restriction";
|
||||
const counts = getCustomerResourceCounts({ object_permission: permission });
|
||||
const toolPermissionKeys = Object.keys(permission?.mcp_tool_permissions ?? {}).filter(
|
||||
(server) => server !== ALL_PROXY_MCP_SERVERS_SENTINEL && server !== NO_MCP_SERVERS_SENTINEL,
|
||||
);
|
||||
const parts = [
|
||||
countLabel(counts.servers, "server"),
|
||||
countLabel(counts.accessGroups, "group"),
|
||||
countLabel(counts.toolsets, "toolset"),
|
||||
countLabel(toolPermissionKeys.length, "tool limit"),
|
||||
].filter(Boolean);
|
||||
return parts.join(", ");
|
||||
}
|
||||
|
||||
const RESOURCE_ITEMS = [
|
||||
{ key: "models", label: "models", icon: Layers, className: "bg-info/10 text-info ring-sky-600/20" },
|
||||
{
|
||||
key: "servers",
|
||||
label: "MCP servers",
|
||||
icon: Server,
|
||||
className:
|
||||
"bg-violet-50 text-violet-700 ring-violet-600/20 dark:bg-violet-950 dark:text-violet-300 dark:ring-violet-400/30",
|
||||
},
|
||||
{
|
||||
key: "accessGroups",
|
||||
label: "MCP access groups",
|
||||
icon: Network,
|
||||
className: "bg-cyan-50 text-cyan-700 ring-cyan-600/20 dark:bg-cyan-950 dark:text-cyan-300 dark:ring-cyan-400/30",
|
||||
},
|
||||
{ key: "toolsets", label: "MCP toolsets", icon: Wrench, className: "bg-warning/10 text-warning ring-amber-600/20" },
|
||||
] as const;
|
||||
|
||||
export function CustomerResourcesCell({ customer }: { customer: EndUser }) {
|
||||
const counts = getCustomerResourceCounts(customer);
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
{RESOURCE_ITEMS.map(({ key, label, icon: Icon, className }) => {
|
||||
const count = counts[key];
|
||||
const countLabel = count === 1 ? label.slice(0, -1) : label;
|
||||
const title = count === "all" ? `All ${label}` : `${count} ${countLabel}`;
|
||||
return (
|
||||
<span
|
||||
key={key}
|
||||
title={title}
|
||||
aria-label={title}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-xs font-medium ring-1 ring-inset [&_svg]:size-3.5",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Icon aria-hidden="true" />
|
||||
<span className="tabular-nums">{count === "all" ? "All" : count}</span>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CustomerRowActions({
|
||||
customer,
|
||||
canModify,
|
||||
premiumUser,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onSetBlocked,
|
||||
isBlocking,
|
||||
}: CustomerTableActions & { customer: EndUser }) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
aria-label={`Actions for ${customer.user_id}`}
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }), "text-muted-foreground")}
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{canModify && (
|
||||
<>
|
||||
<DropdownMenuItem onClick={() => onEdit(customer.user_id)}>
|
||||
<Pencil />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<CustomerBlockAction
|
||||
customer={customer}
|
||||
premiumUser={premiumUser}
|
||||
isBlocking={isBlocking}
|
||||
onSetBlocked={onSetBlocked}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuItem onClick={() => void copyToClipboard(customer.user_id, "Customer ID copied")}>
|
||||
<Copy />
|
||||
Copy ID
|
||||
</DropdownMenuItem>
|
||||
{canModify && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant="destructive" onClick={() => onDelete(customer)}>
|
||||
<Trash2 />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
export function getCustomerTableColumns(actions: CustomerTableActions): ColumnDef<EndUser>[] {
|
||||
return [
|
||||
{
|
||||
id: "user_id",
|
||||
accessorKey: "user_id",
|
||||
meta: { title: "Customer ID", skeleton: "twoLine" },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Customer ID" />,
|
||||
size: 260,
|
||||
cell: ({ row }) => (
|
||||
<IdentityCell
|
||||
title={row.original.alias || row.original.user_id}
|
||||
subtitle={
|
||||
row.original.alias && row.original.alias !== row.original.user_id ? row.original.user_id : undefined
|
||||
}
|
||||
titleClassName={
|
||||
row.original.alias && row.original.alias !== row.original.user_id ? undefined : "font-mono text-xs"
|
||||
}
|
||||
onClick={() => actions.onView(row.original.user_id)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "resources",
|
||||
meta: { title: "Resources", skeleton: "chips" },
|
||||
header: "Resources",
|
||||
size: 250,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => <CustomerResourcesCell customer={row.original} />,
|
||||
},
|
||||
{
|
||||
id: "spend",
|
||||
accessorKey: "spend",
|
||||
meta: { title: "Spend / Budget", skeleton: "meter", numeric: true },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Spend / Budget" />,
|
||||
size: 200,
|
||||
cell: ({ row }) => (
|
||||
<SpendBudgetCell
|
||||
spend={row.original.spend}
|
||||
maxBudget={row.original.litellm_budget_table?.max_budget}
|
||||
spendDecimals={2}
|
||||
budgetDecimals={2}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "max_budget",
|
||||
accessorFn: (customer) => customer.litellm_budget_table?.max_budget,
|
||||
enableHiding: false,
|
||||
enableSorting: false,
|
||||
},
|
||||
{
|
||||
id: "models",
|
||||
meta: { title: "Models", skeleton: "chips" },
|
||||
header: "Models",
|
||||
size: 240,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => <ModelsCell models={row.original.models} unrestrictedLabel="All models the key can use" />,
|
||||
},
|
||||
{
|
||||
id: "limits",
|
||||
meta: { title: "TPM / RPM Limits" },
|
||||
header: "TPM / RPM Limits",
|
||||
size: 150,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) =>
|
||||
row.original.litellm_budget_table?.tpm_limit == null && row.original.litellm_budget_table?.rpm_limit == null ? (
|
||||
"-"
|
||||
) : (
|
||||
<div className="flex flex-col text-xs text-muted-foreground">
|
||||
<span>TPM: {row.original.litellm_budget_table?.tpm_limit ?? "Unlimited"}</span>
|
||||
<span>RPM: {row.original.litellm_budget_table?.rpm_limit ?? "Unlimited"}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "mcp_access",
|
||||
meta: { title: "MCP Access" },
|
||||
header: "MCP Access",
|
||||
size: 210,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => <span className="text-sm">{describeMcpGrant(row.original.object_permission)}</span>,
|
||||
},
|
||||
{
|
||||
id: "blocked",
|
||||
accessorKey: "blocked",
|
||||
meta: { title: "Status", skeleton: "badge" },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Status" />,
|
||||
size: 120,
|
||||
cell: ({ row }) =>
|
||||
row.original.blocked ? <CustomerBlockedBadge /> : <StatusBadge tone="success" label="Active" />,
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: () => <span className="sr-only">Actions</span>,
|
||||
size: 64,
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex justify-end">
|
||||
<CustomerRowActions {...actions} customer={row.original} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
|
@ -0,0 +1,172 @@
|
|||
import Papa from "papaparse";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { EndUser } from "@/app/(dashboard)/hooks/customers/useCustomers";
|
||||
|
||||
import { buildCustomersCsv, buildCustomersCsvRows, exportCustomersToCsv } from "./customerCsvExport";
|
||||
|
||||
const customer = (overrides: Partial<EndUser> = {}): EndUser => ({
|
||||
user_id: "customer-1",
|
||||
blocked: false,
|
||||
spend: 0,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("buildCustomersCsvRows", () => {
|
||||
it("exports customer identity, status, budget, model access, and MCP grants", () => {
|
||||
const customerFields: Partial<EndUser> = {
|
||||
user_id: "customer-42",
|
||||
alias: "finance",
|
||||
blocked: true,
|
||||
spend: 12.5,
|
||||
budget_id: "budget-42",
|
||||
litellm_budget_table: {
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
max_budget: 250,
|
||||
soft_budget: 200,
|
||||
budget_duration: "30d",
|
||||
budget_reset_at: "2026-02-01T00:00:00Z",
|
||||
tpm_limit: 1000,
|
||||
rpm_limit: 50,
|
||||
tpd_limit: 20000,
|
||||
max_parallel_requests: 10,
|
||||
},
|
||||
models: ["model-1", "model-2"],
|
||||
allowed_model_region: "eu",
|
||||
default_model: "model-1",
|
||||
object_permission: {
|
||||
object_permission_id: "permission-42",
|
||||
models: [],
|
||||
mcp_servers: ["server-1", "server-2"],
|
||||
mcp_access_groups: ["group-1"],
|
||||
mcp_toolsets: ["toolset-1"],
|
||||
mcp_tool_permissions: { "server-9": ["write", "read"], "server-1": ["list"] },
|
||||
agents: [],
|
||||
agent_access_groups: [],
|
||||
blocked_tools: [],
|
||||
search_tools: [],
|
||||
vector_stores: [],
|
||||
},
|
||||
};
|
||||
const [row] = buildCustomersCsvRows([customer(customerFields)]);
|
||||
|
||||
const expectedRow = {
|
||||
"Customer Alias": "finance",
|
||||
"Customer ID": "customer-42",
|
||||
Status: "Blocked",
|
||||
"Spend (USD)": 12.5,
|
||||
"Budget ID": "budget-42",
|
||||
"Max Budget (USD)": 250,
|
||||
"Soft Budget (USD)": 200,
|
||||
"Budget Duration": "30d",
|
||||
"Budget Reset At": "2026-02-01T00:00:00Z",
|
||||
"TPM Limit": 1000,
|
||||
"RPM Limit": 50,
|
||||
"TPD Limit": 20000,
|
||||
"Max Parallel Requests": 10,
|
||||
Models: "model-1, model-2",
|
||||
"MCP Servers": "server-1, server-2",
|
||||
"MCP Access Groups": "group-1",
|
||||
"MCP Toolsets": "toolset-1",
|
||||
"MCP Tool Permissions": '{"server-1":["list"],"server-9":["read","write"]}',
|
||||
"Allowed Model Region": "eu",
|
||||
"Default Model": "model-1",
|
||||
};
|
||||
expect(row).toEqual(expectedRow);
|
||||
});
|
||||
|
||||
it("keeps zero limits and spend while rendering missing optional values as empty cells", () => {
|
||||
const missingFields: Partial<EndUser> = {
|
||||
alias: null,
|
||||
budget_id: null,
|
||||
litellm_budget_table: null,
|
||||
object_permission: null,
|
||||
};
|
||||
const [minimal, zeroBudget] = buildCustomersCsvRows([
|
||||
customer(missingFields),
|
||||
customer({
|
||||
user_id: "zero-budget",
|
||||
litellm_budget_table: {
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
budget_id: "nested-budget",
|
||||
max_budget: 0,
|
||||
tpm_limit: 0,
|
||||
rpm_limit: 0,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(minimal.Status).toBe("Active");
|
||||
expect(minimal["Spend (USD)"]).toBe(0);
|
||||
expect(Object.entries(minimal).filter(([key]) => !["Customer ID", "Status", "Spend (USD)"].includes(key))).toEqual(
|
||||
Object.keys(minimal)
|
||||
.filter((key) => !["Customer ID", "Status", "Spend (USD)"].includes(key))
|
||||
.map((key) => [key, ""]),
|
||||
);
|
||||
expect(zeroBudget["Budget ID"]).toBe("nested-budget");
|
||||
expect(zeroBudget["Max Budget (USD)"]).toBe(0);
|
||||
expect(zeroBudget["TPM Limit"]).toBe(0);
|
||||
expect(zeroBudget["RPM Limit"]).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildCustomersCsv", () => {
|
||||
it("round trips quotes, commas, and line breaks without splitting customer rows", () => {
|
||||
const csv = buildCustomersCsv([
|
||||
customer({ alias: 'sales, "east"\nregion', models: ["model-1", "model-2"] }),
|
||||
customer({ user_id: "customer-2" }),
|
||||
]);
|
||||
const parsed = Papa.parse<Record<string, string>>(csv, { header: true });
|
||||
|
||||
expect(parsed.errors).toEqual([]);
|
||||
expect(parsed.data).toHaveLength(2);
|
||||
expect(parsed.data[0]["Customer Alias"]).toBe('sales, "east"\nregion');
|
||||
expect(parsed.data[0].Models).toBe("model-1, model-2");
|
||||
expect(parsed.data[1]["Customer ID"]).toBe("customer-2");
|
||||
});
|
||||
|
||||
it.each(["=SUM(A1:A9)", "+cmd", "-cmd", "@cmd", "\tcmd", "\rcmd", "=SUM(A1:A9)\nsecond line", "+cmd\r\nsecond line"])(
|
||||
"neutralizes formula-leading text %j",
|
||||
(alias) => {
|
||||
const csv = buildCustomersCsv([customer({ alias })]);
|
||||
const parsed = Papa.parse<Record<string, string>>(csv, { header: true });
|
||||
|
||||
expect(parsed.data[0]["Customer Alias"]).toBe(`'${alias}`);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("exportCustomersToCsv", () => {
|
||||
it("downloads the supplied customers, returns their count, and cleans up the download URL", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-01-15T12:00:00Z"));
|
||||
const a = { href: "", download: "", click: vi.fn() };
|
||||
const createObjectURL = vi.fn().mockReturnValue("blob:customers-export");
|
||||
const revokeObjectURL = vi.fn();
|
||||
const createElement = vi.fn().mockReturnValue(a);
|
||||
const appendChild = vi.fn();
|
||||
const removeChild = vi.fn();
|
||||
vi.stubGlobal("window", { URL: { createObjectURL, revokeObjectURL } });
|
||||
vi.stubGlobal("document", { createElement, body: { appendChild, removeChild } });
|
||||
|
||||
expect(exportCustomersToCsv([customer({ user_id: "visible-1" }), customer({ user_id: "visible-2" })])).toBe(2);
|
||||
|
||||
const [blob] = createObjectURL.mock.calls[0] as [Blob];
|
||||
expect(blob.type).toBe("text/csv;charset=utf-8;");
|
||||
expect(
|
||||
Papa.parse<Record<string, string>>(await blob.text(), { header: true }).data.map((row) => row["Customer ID"]),
|
||||
).toEqual(["visible-1", "visible-2"]);
|
||||
expect(createElement).toHaveBeenCalledWith("a");
|
||||
expect(a.href).toBe("blob:customers-export");
|
||||
expect(a.download).toBe("customers_export_2026-01-15.csv");
|
||||
expect(appendChild).toHaveBeenCalledWith(a);
|
||||
expect(a.click).toHaveBeenCalledOnce();
|
||||
expect(removeChild).toHaveBeenCalledWith(a);
|
||||
expect(revokeObjectURL).toHaveBeenCalledWith("blob:customers-export");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
import Papa from "papaparse";
|
||||
|
||||
import type { EndUser } from "@/app/(dashboard)/hooks/customers/useCustomers";
|
||||
|
||||
const cell = (value: string | number | null | undefined): string | number => value ?? "";
|
||||
|
||||
const toolPermissionsCell = (permissions: Readonly<Record<string, readonly string[]>>): string => {
|
||||
const servers = Object.keys(permissions).toSorted();
|
||||
if (servers.length === 0) return "";
|
||||
return JSON.stringify(Object.fromEntries(servers.map((server) => [server, permissions[server].toSorted()])));
|
||||
};
|
||||
|
||||
export const buildCustomersCsvRows = (customers: EndUser[]): Record<string, string | number>[] =>
|
||||
customers.map((customer) => {
|
||||
const budget = customer.litellm_budget_table;
|
||||
const permission = customer.object_permission;
|
||||
return {
|
||||
"Customer Alias": cell(customer.alias),
|
||||
"Customer ID": customer.user_id,
|
||||
Status: customer.blocked ? "Blocked" : "Active",
|
||||
"Spend (USD)": customer.spend,
|
||||
"Budget ID": cell(customer.budget_id ?? budget?.budget_id),
|
||||
"Max Budget (USD)": cell(budget?.max_budget),
|
||||
"Soft Budget (USD)": cell(budget?.soft_budget),
|
||||
"Budget Duration": cell(budget?.budget_duration),
|
||||
"Budget Reset At": cell(budget?.budget_reset_at),
|
||||
"TPM Limit": cell(budget?.tpm_limit),
|
||||
"RPM Limit": cell(budget?.rpm_limit),
|
||||
"TPD Limit": cell(budget?.tpd_limit),
|
||||
"Max Parallel Requests": cell(budget?.max_parallel_requests),
|
||||
Models: (customer.models ?? []).join(", "),
|
||||
"MCP Servers": (permission?.mcp_servers ?? []).join(", "),
|
||||
"MCP Access Groups": (permission?.mcp_access_groups ?? []).join(", "),
|
||||
"MCP Toolsets": (permission?.mcp_toolsets ?? []).join(", "),
|
||||
"MCP Tool Permissions": toolPermissionsCell(permission?.mcp_tool_permissions ?? {}),
|
||||
"Allowed Model Region": cell(customer.allowed_model_region),
|
||||
"Default Model": cell(customer.default_model),
|
||||
};
|
||||
});
|
||||
|
||||
export const buildCustomersCsv = (customers: EndUser[]): string =>
|
||||
Papa.unparse(buildCustomersCsvRows(customers), { escapeFormulae: /^[=+\-@\t\r]/ });
|
||||
|
||||
export const exportCustomersToCsv = (customers: EndUser[]): number => {
|
||||
const blob = new Blob([buildCustomersCsv(customers)], { type: "text/csv;charset=utf-8;" });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `customers_export_${new Date().toISOString().split("T")[0]}.csv`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
window.URL.revokeObjectURL(url);
|
||||
return customers.length;
|
||||
};
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { EndUser } from "@/app/(dashboard)/hooks/customers/useCustomers";
|
||||
import { ALL_PROXY_MCP_SERVERS_SENTINEL, NO_MCP_SERVERS_SENTINEL } from "@/components/mcp_tools/constants";
|
||||
|
||||
import { customerMatchesFilter, customerMatchesSearch, formatCustomerFilterValue } from "./customerFilters";
|
||||
|
||||
const customer: EndUser = { user_id: "customer-1", alias: "Research", blocked: false, spend: 0 };
|
||||
const permission: NonNullable<EndUser["object_permission"]> = {
|
||||
object_permission_id: "permission-1",
|
||||
mcp_servers: [],
|
||||
mcp_access_groups: [],
|
||||
mcp_toolsets: [],
|
||||
mcp_tool_permissions: {},
|
||||
agent_access_groups: [],
|
||||
agents: [],
|
||||
models: [],
|
||||
blocked_tools: [],
|
||||
search_tools: [],
|
||||
vector_stores: [],
|
||||
};
|
||||
|
||||
const MCP_SCOPES = ["limited", "denied", "unrestricted"] as const;
|
||||
|
||||
const scopesMatching = (row: EndUser): string[] =>
|
||||
MCP_SCOPES.filter((scope) => customerMatchesFilter(row, "mcp_access", scope));
|
||||
|
||||
describe("customer filters", () => {
|
||||
it("searches ID and alias case-insensitively and trims the search", () => {
|
||||
expect(customerMatchesSearch(customer, " CUSTOMER-1 ")).toBe(true);
|
||||
expect(customerMatchesSearch(customer, "research")).toBe(true);
|
||||
expect(customerMatchesSearch({ ...customer, alias: null }, "research")).toBe(false);
|
||||
expect(customerMatchesSearch(customer, " ")).toBe(true);
|
||||
});
|
||||
|
||||
it.each([false, true])("filters both blocked and active for blocked=%s", (blocked) => {
|
||||
const row = { ...customer, blocked };
|
||||
expect(customerMatchesFilter(row, "blocked", "blocked")).toBe(blocked);
|
||||
expect(customerMatchesFilter(row, "blocked", "active")).toBe(!blocked);
|
||||
});
|
||||
|
||||
it.each([0, 25, null, undefined])("uses a monetary limit for max_budget=%s", (max_budget) => {
|
||||
const row = { ...customer, litellm_budget_table: { created_at: "2026-01-01T00:00:00Z", max_budget } };
|
||||
expect(customerMatchesFilter(row, "max_budget", "yes")).toBe(max_budget != null);
|
||||
expect(customerMatchesFilter(row, "max_budget", "no")).toBe(max_budget == null);
|
||||
});
|
||||
|
||||
it("treats absent budgets and an unlimited budget link as no monetary budget", () => {
|
||||
expect(customerMatchesFilter(customer, "max_budget", "no")).toBe(true);
|
||||
expect(customerMatchesFilter({ ...customer, budget_id: "budget-id" }, "max_budget", "yes")).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ mcp_servers: ["server-1"] },
|
||||
{ mcp_access_groups: ["group-1"] },
|
||||
{ mcp_toolsets: ["toolset-1"] },
|
||||
{ mcp_tool_permissions: { "server-1": ["read"] } },
|
||||
])("detects configured MCP grants %j", (grant) => {
|
||||
const row = { ...customer, object_permission: { ...permission, ...grant } };
|
||||
expect(scopesMatching(row)).toEqual(["limited"]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ mcp_servers: [NO_MCP_SERVERS_SENTINEL], mcp_access_groups: ["group-1"] },
|
||||
{ mcp_servers: [ALL_PROXY_MCP_SERVERS_SENTINEL] },
|
||||
])("matches denied MCP grants only as No MCP access %j", (grant) => {
|
||||
expect(scopesMatching({ ...customer, object_permission: { ...permission, ...grant } })).toEqual(["denied"]);
|
||||
});
|
||||
|
||||
it.each([undefined, null, permission])(
|
||||
"matches customers without a grant only as No MCP restriction (%j)",
|
||||
(object_permission) => {
|
||||
expect(scopesMatching({ ...customer, object_permission })).toEqual(["unrestricted"]);
|
||||
},
|
||||
);
|
||||
|
||||
it("ignores invalid URL filter values and unrelated columns", () => {
|
||||
expect(customerMatchesFilter(customer, "blocked", "unexpected")).toBe(true);
|
||||
expect(customerMatchesFilter(customer, "max_budget", false)).toBe(true);
|
||||
expect(customerMatchesFilter(customer, "mcp_access", "unexpected")).toBe(true);
|
||||
expect(customerMatchesFilter(customer, "mcp_access", "yes")).toBe(true);
|
||||
expect(customerMatchesFilter(customer, "unrelated", "yes")).toBe(true);
|
||||
});
|
||||
|
||||
it("formats filter chips with the labels shown in the drawer", () => {
|
||||
expect(formatCustomerFilterValue("blocked", "active")).toBe("Active");
|
||||
expect(formatCustomerFilterValue("max_budget", "yes")).toBe("Has budget");
|
||||
expect(formatCustomerFilterValue("mcp_access", "unrestricted")).toBe("No MCP restriction");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
import type { FilterFn } from "@tanstack/react-table";
|
||||
|
||||
import type { EndUser } from "@/app/(dashboard)/hooks/customers/useCustomers";
|
||||
|
||||
import { customerMcpScope } from "./customerResources";
|
||||
|
||||
export const CUSTOMER_FILTER_FIELDS = [
|
||||
{
|
||||
id: "blocked",
|
||||
label: "Status",
|
||||
options: [
|
||||
{ value: "active", label: "Active" },
|
||||
{ value: "blocked", label: "Blocked" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "max_budget",
|
||||
label: "Budget",
|
||||
options: [
|
||||
{ value: "yes", label: "Has budget" },
|
||||
{ value: "no", label: "No budget" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "mcp_access",
|
||||
label: "MCP access",
|
||||
options: [
|
||||
{ value: "limited", label: "Limited MCP access" },
|
||||
{ value: "denied", label: "No MCP access" },
|
||||
{ value: "unrestricted", label: "No MCP restriction" },
|
||||
],
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const customerMatchesSearch = (customer: EndUser, search: string): boolean =>
|
||||
`${customer.user_id} ${customer.alias ?? ""}`.toLowerCase().includes(search.trim().toLowerCase());
|
||||
|
||||
export const customerMatchesFilter = (customer: EndUser, columnId: string, value: unknown): boolean => {
|
||||
if (columnId === "blocked") {
|
||||
if (value === "blocked") return Boolean(customer.blocked);
|
||||
if (value === "active") return !customer.blocked;
|
||||
return true;
|
||||
}
|
||||
if (columnId === "mcp_access") {
|
||||
if (value !== "limited" && value !== "denied" && value !== "unrestricted") return true;
|
||||
return customerMcpScope(customer.object_permission) === value;
|
||||
}
|
||||
if (value !== "yes" && value !== "no") return true;
|
||||
if (columnId === "max_budget") return (customer.litellm_budget_table?.max_budget != null) === (value === "yes");
|
||||
return true;
|
||||
};
|
||||
|
||||
export const customerFilterFn: FilterFn<EndUser> = (row, columnId, value: unknown) =>
|
||||
customerMatchesFilter(row.original, columnId, value);
|
||||
|
||||
export const formatCustomerFilterValue = (columnId: string, value: unknown): string =>
|
||||
CUSTOMER_FILTER_FIELDS.find((field) => field.id === columnId)?.options.find((option) => option.value === value)
|
||||
?.label ?? String(value);
|
||||
|
|
@ -0,0 +1,511 @@
|
|||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { EndUser } from "@/app/(dashboard)/hooks/customers/useCustomers";
|
||||
import type { MCPServer, MCPToolset } from "@/components/mcp_tools/types";
|
||||
import { NO_MCP_SERVERS_SENTINEL } from "@/components/mcp_tools/constants";
|
||||
import {
|
||||
buildCustomerPayload,
|
||||
getCustomerBudgetCounts,
|
||||
hasSharedCustomerBudget,
|
||||
toCustomerFormValues,
|
||||
} from "./customerPayload";
|
||||
|
||||
const customer: EndUser = { user_id: "customer-id", blocked: false, spend: 0 };
|
||||
const permission = {
|
||||
object_permission_id: "permission-id",
|
||||
mcp_servers: ["server-id"],
|
||||
mcp_access_groups: ["group-id"],
|
||||
mcp_toolsets: ["toolset-id"],
|
||||
mcp_tool_permissions: { "server-id": ["read", "write"] },
|
||||
agent_access_groups: [],
|
||||
agents: [],
|
||||
blocked_tools: [],
|
||||
models: [],
|
||||
search_tools: [],
|
||||
skills: [],
|
||||
vector_stores: [],
|
||||
};
|
||||
|
||||
const server: MCPServer = {
|
||||
server_id: "server-id",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
created_by: "admin",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
updated_by: "admin",
|
||||
};
|
||||
|
||||
describe("shared customer budgets", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("counts customers per budget on browsers without Map.groupBy", () => {
|
||||
vi.stubGlobal("Map", Object.assign(class extends Map {}, { groupBy: undefined }));
|
||||
const linked = { ...customer, budget_id: "shared-budget" };
|
||||
|
||||
expect(
|
||||
getCustomerBudgetCounts([
|
||||
linked,
|
||||
{ ...linked, user_id: "another-customer" },
|
||||
{ ...customer, user_id: "no-budget" },
|
||||
]),
|
||||
).toEqual(new Map([["shared-budget", 2]]));
|
||||
});
|
||||
|
||||
it("allows a budget linked to only one customer", () => {
|
||||
const owned = { ...customer, budget_id: "own-budget" };
|
||||
expect(
|
||||
hasSharedCustomerBudget(
|
||||
owned,
|
||||
getCustomerBudgetCounts([owned, { ...customer, user_id: "another-customer", budget_id: "other-budget" }]),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("guards a budget referenced by another customer", () => {
|
||||
const shared = { ...customer, budget_id: "shared-budget" };
|
||||
expect(
|
||||
hasSharedCustomerBudget(shared, getCustomerBudgetCounts([shared, { ...shared, user_id: "another-customer" }])),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not treat customers without budgets as sharing a budget", () => {
|
||||
expect(
|
||||
hasSharedCustomerBudget(
|
||||
customer,
|
||||
getCustomerBudgetCounts([customer, { ...customer, user_id: "another-customer" }]),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("customer form values", () => {
|
||||
it("starts with empty optional values and grants", () => {
|
||||
const expected = {
|
||||
user_id: "",
|
||||
alias: "",
|
||||
budget_id: "",
|
||||
max_budget: "",
|
||||
models: [],
|
||||
allowed_model_region: "",
|
||||
default_model: "",
|
||||
mcp_servers_and_groups: { servers: [], accessGroups: [], toolsets: [] },
|
||||
mcp_tool_permissions: {},
|
||||
};
|
||||
expect(toCustomerFormValues(null)).toEqual(expected);
|
||||
});
|
||||
|
||||
it("loads the stored identity, models and MCP grant", () => {
|
||||
const stored: EndUser = {
|
||||
...customer,
|
||||
alias: "Alias",
|
||||
budget_id: "budget-id",
|
||||
models: ["model-id"],
|
||||
allowed_model_region: "eu",
|
||||
default_model: "default-model",
|
||||
object_permission: permission,
|
||||
litellm_budget_table: { max_budget: 50, created_at: "2026-01-01T00:00:00Z" },
|
||||
};
|
||||
const expected = {
|
||||
user_id: stored.user_id,
|
||||
alias: stored.alias,
|
||||
budget_id: stored.budget_id,
|
||||
max_budget: "",
|
||||
models: stored.models,
|
||||
allowed_model_region: stored.allowed_model_region,
|
||||
default_model: stored.default_model,
|
||||
mcp_servers_and_groups: {
|
||||
servers: permission.mcp_servers,
|
||||
accessGroups: permission.mcp_access_groups,
|
||||
toolsets: permission.mcp_toolsets,
|
||||
},
|
||||
mcp_tool_permissions: permission.mcp_tool_permissions,
|
||||
};
|
||||
expect(toCustomerFormValues(stored)).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("customer payload", () => {
|
||||
it("does not revive stale tool grants when replacing No MCP Servers", () => {
|
||||
const anotherServer = { ...server, server_id: "another-server" };
|
||||
const stored = {
|
||||
...customer,
|
||||
object_permission: {
|
||||
...permission,
|
||||
mcp_servers: [NO_MCP_SERVERS_SENTINEL],
|
||||
mcp_access_groups: [],
|
||||
mcp_toolsets: [],
|
||||
mcp_tool_permissions: { [anotherServer.server_id]: ["read"] },
|
||||
},
|
||||
};
|
||||
const values = {
|
||||
...toCustomerFormValues(stored),
|
||||
mcp_servers_and_groups: { servers: [server.server_id], accessGroups: [], toolsets: [] },
|
||||
mcp_tool_permissions: { ...stored.object_permission.mcp_tool_permissions, [server.server_id]: ["read"] },
|
||||
};
|
||||
|
||||
const expected = {
|
||||
mcp_servers: [server.server_id],
|
||||
mcp_access_groups: [],
|
||||
mcp_toolsets: [],
|
||||
mcp_tool_permissions: { [server.server_id]: ["read"] },
|
||||
};
|
||||
expect(buildCustomerPayload(values, stored, [server, anotherServer], []).object_permission).toEqual(expected);
|
||||
});
|
||||
|
||||
it("creates a customer with only the fields that are set", () => {
|
||||
const values = { ...toCustomerFormValues(null), user_id: " customer-id " };
|
||||
expect(buildCustomerPayload(values, null, [], [])).toEqual({ user_id: customer.user_id });
|
||||
});
|
||||
|
||||
it("drops generated tool grants when a newly selected server is removed before create", () => {
|
||||
const values = {
|
||||
...toCustomerFormValues(null),
|
||||
user_id: customer.user_id,
|
||||
mcp_tool_permissions: { [server.server_id]: ["read"] },
|
||||
};
|
||||
|
||||
expect(buildCustomerPayload(values, null, [server], [])).toEqual({ user_id: customer.user_id });
|
||||
});
|
||||
|
||||
it("drops a new server's generated tool grants after returning to the stored selection", () => {
|
||||
const stored = { ...customer, object_permission: { ...permission, mcp_access_groups: [], mcp_toolsets: [] } };
|
||||
const anotherServer = { ...server, server_id: "another-server" };
|
||||
const values = {
|
||||
...toCustomerFormValues(stored),
|
||||
mcp_tool_permissions: { ...permission.mcp_tool_permissions, [anotherServer.server_id]: ["list"] },
|
||||
};
|
||||
|
||||
expect(buildCustomerPayload(values, stored, [server, anotherServer], [])).toEqual({
|
||||
user_id: customer.user_id,
|
||||
});
|
||||
});
|
||||
|
||||
it("omits unchanged model, region, default and budget settings on an alias edit", () => {
|
||||
const stored: EndUser = {
|
||||
...customer,
|
||||
models: ["model-a", "model-b"],
|
||||
allowed_model_region: "eu",
|
||||
default_model: "model-a",
|
||||
litellm_budget_table: { max_budget: 50, created_at: "2026-01-01T00:00:00Z" },
|
||||
};
|
||||
const values = { ...toCustomerFormValues(stored), alias: "Updated alias", models: ["model-b", "model-a"] };
|
||||
|
||||
expect(buildCustomerPayload(values, stored, [], [])).toEqual({
|
||||
user_id: stored.user_id,
|
||||
alias: values.alias,
|
||||
});
|
||||
});
|
||||
|
||||
it("includes intentional model, region, default and budget changes", () => {
|
||||
const stored: EndUser = {
|
||||
...customer,
|
||||
models: ["model-a"],
|
||||
allowed_model_region: "eu",
|
||||
default_model: "model-a",
|
||||
litellm_budget_table: { max_budget: 50, created_at: "2026-01-01T00:00:00Z" },
|
||||
};
|
||||
const values = {
|
||||
...toCustomerFormValues(stored),
|
||||
models: [],
|
||||
allowed_model_region: "us" as const,
|
||||
default_model: "model-b",
|
||||
max_budget: "25",
|
||||
};
|
||||
|
||||
const expected = {
|
||||
user_id: stored.user_id,
|
||||
models: [],
|
||||
allowed_model_region: "us",
|
||||
default_model: "model-b",
|
||||
max_budget: 25,
|
||||
};
|
||||
expect(buildCustomerPayload(values, stored, [], [])).toEqual(expected);
|
||||
});
|
||||
|
||||
it("keeps zero budgets and trims optional text fields", () => {
|
||||
const values = {
|
||||
...toCustomerFormValues(null),
|
||||
user_id: customer.user_id,
|
||||
alias: " Alias ",
|
||||
max_budget: "0",
|
||||
models: ["model-id"],
|
||||
allowed_model_region: "us" as const,
|
||||
default_model: " default-model ",
|
||||
};
|
||||
const expected = {
|
||||
user_id: customer.user_id,
|
||||
alias: "Alias",
|
||||
max_budget: 0,
|
||||
models: values.models,
|
||||
allowed_model_region: values.allowed_model_region,
|
||||
default_model: "default-model",
|
||||
};
|
||||
expect(buildCustomerPayload(values, null, [], [])).toEqual(expected);
|
||||
});
|
||||
|
||||
it("sends a selected existing budget", () => {
|
||||
const values = { ...toCustomerFormValues(null), user_id: customer.user_id, budget_id: "budget-id" };
|
||||
expect(buildCustomerPayload(values, null, [], [])).toEqual({
|
||||
user_id: customer.user_id,
|
||||
budget_id: values.budget_id,
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["", " "])("clears a stored alias when the form contains %j", (alias) => {
|
||||
const stored = { ...customer, alias: "Alias" };
|
||||
const values = { ...toCustomerFormValues(stored), alias };
|
||||
|
||||
expect(buildCustomerPayload(values, stored, [], [])).toEqual({
|
||||
user_id: stored.user_id,
|
||||
alias: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("includes the grant when a toolset is chosen on create", () => {
|
||||
const values = {
|
||||
...toCustomerFormValues(null),
|
||||
user_id: customer.user_id,
|
||||
mcp_servers_and_groups: { servers: [], accessGroups: [], toolsets: ["toolset-id"] },
|
||||
};
|
||||
const grant = {
|
||||
mcp_servers: [],
|
||||
mcp_access_groups: [],
|
||||
mcp_toolsets: values.mcp_servers_and_groups.toolsets,
|
||||
mcp_tool_permissions: {},
|
||||
};
|
||||
expect(buildCustomerPayload(values, null, [], [])).toEqual({ user_id: customer.user_id, object_permission: grant });
|
||||
});
|
||||
|
||||
it("omits the stored MCP grant on an unrelated edit, regardless of ordering", () => {
|
||||
const stored = {
|
||||
...customer,
|
||||
object_permission: {
|
||||
...permission,
|
||||
mcp_servers: ["server-id", "another-server"],
|
||||
mcp_access_groups: ["group-id", "another-group"],
|
||||
mcp_toolsets: ["toolset-id", "another-toolset"],
|
||||
mcp_tool_permissions: { "server-id": ["read", "write"], "another-server": ["list"] },
|
||||
},
|
||||
};
|
||||
const values = {
|
||||
...toCustomerFormValues(stored),
|
||||
alias: "New alias",
|
||||
mcp_servers_and_groups: {
|
||||
servers: ["another-server", "server-id"],
|
||||
accessGroups: ["another-group", "group-id"],
|
||||
toolsets: ["another-toolset", "toolset-id"],
|
||||
},
|
||||
mcp_tool_permissions: { "another-server": ["list"], "server-id": ["write", "read"] },
|
||||
};
|
||||
expect(buildCustomerPayload(values, stored, [server], [])).toEqual({
|
||||
user_id: customer.user_id,
|
||||
alias: values.alias,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps stored tool restrictions untouched when only the alias changes", () => {
|
||||
const stored = {
|
||||
...customer,
|
||||
object_permission: { ...permission, mcp_servers: [], mcp_access_groups: [], mcp_toolsets: [] },
|
||||
};
|
||||
const values = { ...toCustomerFormValues(stored), alias: "New alias" };
|
||||
expect(buildCustomerPayload(values, stored, [server], [])).toEqual({
|
||||
user_id: customer.user_id,
|
||||
alias: values.alias,
|
||||
});
|
||||
});
|
||||
|
||||
it("saves edits to a server granted only through its tool permissions", () => {
|
||||
const stored = {
|
||||
...customer,
|
||||
object_permission: {
|
||||
...permission,
|
||||
mcp_servers: [],
|
||||
mcp_access_groups: [],
|
||||
mcp_toolsets: [],
|
||||
mcp_tool_permissions: { "server-id": ["read"] },
|
||||
},
|
||||
};
|
||||
const values = { ...toCustomerFormValues(stored), mcp_tool_permissions: { "server-id": ["write"] } };
|
||||
const grant = {
|
||||
mcp_servers: [],
|
||||
mcp_access_groups: [],
|
||||
mcp_toolsets: [],
|
||||
mcp_tool_permissions: values.mcp_tool_permissions,
|
||||
};
|
||||
|
||||
expect(buildCustomerPayload(values, stored, [server], [])).toEqual({
|
||||
user_id: customer.user_id,
|
||||
object_permission: grant,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ kind: "server", allowedTools: ["read"] },
|
||||
{ kind: "toolset", allowedTools: ["write"] },
|
||||
])("keeps an independent tool grant when adding a $kind", ({ kind, allowedTools }) => {
|
||||
const stored = {
|
||||
...customer,
|
||||
object_permission: {
|
||||
...permission,
|
||||
mcp_servers: [],
|
||||
mcp_access_groups: [],
|
||||
mcp_toolsets: [],
|
||||
mcp_tool_permissions: { "server-id": ["read"] },
|
||||
},
|
||||
};
|
||||
const anotherServer = { ...server, server_id: "another-server" };
|
||||
const toolsets: MCPToolset[] = [
|
||||
{
|
||||
toolset_id: "toolset-id",
|
||||
toolset_name: "Toolset",
|
||||
tools: [{ server_id: anotherServer.server_id, tool_name: "list" }],
|
||||
},
|
||||
];
|
||||
const selection = {
|
||||
servers: kind === "server" ? [anotherServer.server_id] : [],
|
||||
accessGroups: [],
|
||||
toolsets: kind === "toolset" ? [toolsets[0].toolset_id] : [],
|
||||
};
|
||||
const values = {
|
||||
...toCustomerFormValues(stored),
|
||||
mcp_servers_and_groups: selection,
|
||||
mcp_tool_permissions: { "server-id": allowedTools },
|
||||
};
|
||||
const grant = {
|
||||
mcp_servers: selection.servers,
|
||||
mcp_access_groups: [],
|
||||
mcp_toolsets: selection.toolsets,
|
||||
mcp_tool_permissions: values.mcp_tool_permissions,
|
||||
};
|
||||
|
||||
expect(buildCustomerPayload(values, stored, [server, anotherServer], toolsets)).toEqual({
|
||||
user_id: customer.user_id,
|
||||
object_permission: grant,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a canonical server edit from an independent shared-alias grant when adding a server", () => {
|
||||
const stored = {
|
||||
...customer,
|
||||
object_permission: {
|
||||
...permission,
|
||||
mcp_servers: [],
|
||||
mcp_access_groups: [],
|
||||
mcp_toolsets: [],
|
||||
mcp_tool_permissions: { wiki: ["read"] },
|
||||
},
|
||||
};
|
||||
const sharedServer = { ...server, alias: "wiki" };
|
||||
const anotherSharedServer = { ...sharedServer, server_id: "another-shared-server" };
|
||||
const addedServer = { ...server, server_id: "added-server" };
|
||||
const values = {
|
||||
...toCustomerFormValues(stored),
|
||||
mcp_servers_and_groups: { servers: [addedServer.server_id], accessGroups: [], toolsets: [] },
|
||||
mcp_tool_permissions: { wiki: ["read"], [sharedServer.server_id]: ["write"] },
|
||||
};
|
||||
const grant = {
|
||||
mcp_servers: values.mcp_servers_and_groups.servers,
|
||||
mcp_access_groups: [],
|
||||
mcp_toolsets: [],
|
||||
mcp_tool_permissions: values.mcp_tool_permissions,
|
||||
};
|
||||
|
||||
expect(buildCustomerPayload(values, stored, [sharedServer, anotherSharedServer, addedServer], [])).toEqual({
|
||||
user_id: customer.user_id,
|
||||
object_permission: grant,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ edit: "only the alias", servers: ["server-a"], expected: undefined },
|
||||
{
|
||||
edit: "the server selection",
|
||||
servers: ["server-a", "server-c"],
|
||||
expected: {
|
||||
mcp_servers: ["server-a", "server-c"],
|
||||
mcp_access_groups: [],
|
||||
mcp_toolsets: [],
|
||||
mcp_tool_permissions: { "server-a": ["read"], "server-b": ["read"] },
|
||||
},
|
||||
},
|
||||
])("keeps the other server behind a shared alias key when editing $edit", ({ servers, expected }) => {
|
||||
const sharedServer = { ...server, server_id: "server-a", alias: "shared" };
|
||||
const anotherSharedServer = { ...server, server_id: "server-b", alias: "shared" };
|
||||
const addedServer = { ...server, server_id: "server-c" };
|
||||
const stored = {
|
||||
...customer,
|
||||
object_permission: {
|
||||
...permission,
|
||||
mcp_servers: [sharedServer.server_id],
|
||||
mcp_access_groups: [],
|
||||
mcp_toolsets: [],
|
||||
mcp_tool_permissions: { shared: ["read"] },
|
||||
},
|
||||
};
|
||||
const values = {
|
||||
...toCustomerFormValues(stored),
|
||||
alias: "New alias",
|
||||
mcp_servers_and_groups: { servers, accessGroups: [], toolsets: [] },
|
||||
};
|
||||
|
||||
expect(
|
||||
buildCustomerPayload(values, stored, [sharedServer, anotherSharedServer, addedServer], []).object_permission,
|
||||
).toEqual(expected);
|
||||
});
|
||||
|
||||
it("keeps the other shared-alias server once the selected one is deselected", () => {
|
||||
const sharedServer = { ...server, server_id: "server-a", alias: "shared" };
|
||||
const anotherSharedServer = { ...server, server_id: "server-b", alias: "shared" };
|
||||
const keptServer = { ...server, server_id: "server-c" };
|
||||
const stored = {
|
||||
...customer,
|
||||
object_permission: {
|
||||
...permission,
|
||||
mcp_servers: [sharedServer.server_id, keptServer.server_id],
|
||||
mcp_access_groups: [],
|
||||
mcp_toolsets: [],
|
||||
mcp_tool_permissions: { shared: ["read"] },
|
||||
},
|
||||
};
|
||||
const values = {
|
||||
...toCustomerFormValues(stored),
|
||||
mcp_servers_and_groups: { servers: [keptServer.server_id], accessGroups: [], toolsets: [] },
|
||||
};
|
||||
const grant = {
|
||||
mcp_servers: [keptServer.server_id],
|
||||
mcp_access_groups: [],
|
||||
mcp_toolsets: [],
|
||||
mcp_tool_permissions: { [anotherSharedServer.server_id]: ["read"] },
|
||||
};
|
||||
|
||||
expect(
|
||||
buildCustomerPayload(values, stored, [sharedServer, anotherSharedServer, keptServer], []).object_permission,
|
||||
).toEqual(grant);
|
||||
});
|
||||
|
||||
it("keeps the stored ID of an existing customer exactly as stored", () => {
|
||||
const stored = { ...customer, user_id: " customer-id " };
|
||||
|
||||
expect(buildCustomerPayload(toCustomerFormValues(stored), stored, [], []).user_id).toBe(" customer-id ");
|
||||
});
|
||||
|
||||
it("sends an empty models list when clearing the selection on update", () => {
|
||||
const stored = { ...customer, models: ["model-id"] };
|
||||
const values = { ...toCustomerFormValues(stored), models: [] };
|
||||
expect(buildCustomerPayload(values, stored, [], [])).toEqual({ user_id: customer.user_id, models: [] });
|
||||
});
|
||||
|
||||
it("drops the tool restriction for a deselected server", () => {
|
||||
const stored = { ...customer, object_permission: { ...permission, mcp_access_groups: [], mcp_toolsets: [] } };
|
||||
const values = {
|
||||
...toCustomerFormValues(stored),
|
||||
mcp_servers_and_groups: { servers: [], accessGroups: [], toolsets: [] },
|
||||
};
|
||||
const grant = { mcp_servers: [], mcp_access_groups: [], mcp_toolsets: [], mcp_tool_permissions: {} };
|
||||
expect(buildCustomerPayload(values, stored, [server], [])).toEqual({
|
||||
user_id: customer.user_id,
|
||||
object_permission: grant,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,178 @@
|
|||
import type { EndUser } from "@/app/(dashboard)/hooks/customers/useCustomers";
|
||||
import type { UpdateCustomerRequest } from "@/app/(dashboard)/hooks/customers/customerApi";
|
||||
import type { MCPServer, MCPToolset } from "@/components/mcp_tools/types";
|
||||
import { NO_MCP_SERVERS_SENTINEL } from "@/components/mcp_tools/constants";
|
||||
import {
|
||||
accessGroupNamesOf,
|
||||
mcpAllowedToolsFor,
|
||||
mcpServersForIdentifier,
|
||||
} from "@/components/mcp_server_management/effectiveMcpServers";
|
||||
import {
|
||||
extractMcpEntitlementUpdate,
|
||||
type McpEntitlementUpdate,
|
||||
} from "@/components/mcp_server_management/mcpEntitlement";
|
||||
|
||||
export const getCustomerBudgetCounts = (customers: readonly EndUser[]): ReadonlyMap<string, number> =>
|
||||
customers.reduce(
|
||||
(counts, { budget_id }) => (budget_id ? counts.set(budget_id, (counts.get(budget_id) ?? 0) + 1) : counts),
|
||||
new Map<string, number>(),
|
||||
);
|
||||
|
||||
export const hasSharedCustomerBudget = (customer: EndUser, budgetCounts: ReadonlyMap<string, number>): boolean =>
|
||||
Boolean(customer.budget_id && (budgetCounts.get(customer.budget_id) ?? 0) > 1);
|
||||
|
||||
export type CustomerFormValues = {
|
||||
user_id: string;
|
||||
alias: string;
|
||||
budget_id: string;
|
||||
max_budget: string;
|
||||
models: string[];
|
||||
allowed_model_region: "" | "eu" | "us";
|
||||
default_model: string;
|
||||
mcp_servers_and_groups: { servers: string[]; accessGroups: string[]; toolsets: string[] };
|
||||
mcp_tool_permissions: Record<string, string[]>;
|
||||
};
|
||||
|
||||
const storedMcpGrant = (customer: EndUser | null): McpEntitlementUpdate => ({
|
||||
mcp_servers: customer?.object_permission?.mcp_servers ?? [],
|
||||
mcp_access_groups: customer?.object_permission?.mcp_access_groups ?? [],
|
||||
mcp_toolsets: customer?.object_permission?.mcp_toolsets ?? [],
|
||||
mcp_tool_permissions: customer?.object_permission?.mcp_tool_permissions ?? {},
|
||||
});
|
||||
|
||||
export const toCustomerFormValues = (customer: EndUser | null): CustomerFormValues => {
|
||||
const grant = storedMcpGrant(customer);
|
||||
const maxBudget = customer?.litellm_budget_table?.max_budget;
|
||||
return {
|
||||
user_id: customer?.user_id ?? "",
|
||||
alias: customer?.alias ?? "",
|
||||
budget_id: customer?.budget_id ?? "",
|
||||
max_budget: customer?.budget_id || maxBudget == null ? "" : String(maxBudget),
|
||||
models: customer?.models ?? [],
|
||||
allowed_model_region: customer?.allowed_model_region ?? "",
|
||||
default_model: customer?.default_model ?? "",
|
||||
mcp_servers_and_groups: {
|
||||
servers: grant.mcp_servers,
|
||||
accessGroups: grant.mcp_access_groups,
|
||||
toolsets: grant.mcp_toolsets,
|
||||
},
|
||||
mcp_tool_permissions: grant.mcp_tool_permissions,
|
||||
};
|
||||
};
|
||||
|
||||
const stringsIdentity = (values: readonly string[]): string => JSON.stringify([...new Set(values)].sort());
|
||||
|
||||
const optionalCustomerFields = (
|
||||
values: CustomerFormValues,
|
||||
existing: EndUser | null,
|
||||
): Partial<UpdateCustomerRequest> => ({
|
||||
...(values.alias.trim() !== (existing?.alias ?? "") ? { alias: values.alias.trim() } : {}),
|
||||
...(values.budget_id && values.budget_id !== existing?.budget_id ? { budget_id: values.budget_id } : {}),
|
||||
...(values.max_budget.trim() && Number(values.max_budget) !== existing?.litellm_budget_table?.max_budget
|
||||
? { max_budget: Number(values.max_budget) }
|
||||
: {}),
|
||||
...(values.allowed_model_region && values.allowed_model_region !== existing?.allowed_model_region
|
||||
? { allowed_model_region: values.allowed_model_region }
|
||||
: {}),
|
||||
...(values.default_model.trim() && values.default_model.trim() !== existing?.default_model?.trim()
|
||||
? { default_model: values.default_model.trim() }
|
||||
: {}),
|
||||
});
|
||||
|
||||
export const buildCustomerPayload = (
|
||||
values: CustomerFormValues,
|
||||
existing: EndUser | null,
|
||||
servers: MCPServer[],
|
||||
toolsets: MCPToolset[],
|
||||
): UpdateCustomerRequest => {
|
||||
const grant = extractMcpEntitlementUpdate(values, existing?.object_permission, servers, toolsets);
|
||||
return {
|
||||
user_id: existing?.user_id ?? values.user_id.trim(),
|
||||
...optionalCustomerFields(values, existing),
|
||||
...(stringsIdentity(values.models) !== stringsIdentity(existing?.models ?? []) ? { models: values.models } : {}),
|
||||
...(grant ? { object_permission: grant } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
const customerSecurityIdentity = (customer: EndUser): string => {
|
||||
const grant = storedMcpGrant(customer);
|
||||
const identity = {
|
||||
models: stringsIdentity(customer.models ?? []),
|
||||
allowed_model_region: customer.allowed_model_region ?? "",
|
||||
default_model: customer.default_model ?? "",
|
||||
budget_id: customer.budget_id ?? "",
|
||||
max_budget: customer.litellm_budget_table?.max_budget ?? null,
|
||||
mcp_servers: stringsIdentity(grant.mcp_servers),
|
||||
mcp_access_groups: stringsIdentity(grant.mcp_access_groups),
|
||||
mcp_toolsets: stringsIdentity(grant.mcp_toolsets),
|
||||
mcp_tool_permissions: Object.keys(grant.mcp_tool_permissions)
|
||||
.sort()
|
||||
.map((key) => [key, stringsIdentity(grant.mcp_tool_permissions[key])]),
|
||||
};
|
||||
return JSON.stringify(identity);
|
||||
};
|
||||
|
||||
export const hasCustomerEditConflict = (
|
||||
payload: UpdateCustomerRequest,
|
||||
baseline: EndUser | null,
|
||||
current: EndUser | null,
|
||||
): boolean => {
|
||||
if (!baseline || !current) return baseline !== current;
|
||||
if (baseline.user_id !== current.user_id) return true;
|
||||
const changesSecurity = Object.keys(payload).some((key) => key !== "user_id" && key !== "alias");
|
||||
return changesSecurity && customerSecurityIdentity(baseline) !== customerSecurityIdentity(current);
|
||||
};
|
||||
|
||||
export const directAndGroupServerIds = (grant: McpEntitlementUpdate, servers: MCPServer[]): ReadonlySet<string> => {
|
||||
if (grant.mcp_servers.includes(NO_MCP_SERVERS_SENTINEL)) return new Set();
|
||||
const directIds = grant.mcp_servers.flatMap((identifier) =>
|
||||
mcpServersForIdentifier(servers, identifier).map((server) => server.server_id),
|
||||
);
|
||||
const groupIds = servers
|
||||
.filter((server) => accessGroupNamesOf(server).some((group) => grant.mcp_access_groups.includes(group)))
|
||||
.map((server) => server.server_id);
|
||||
return new Set([...directIds, ...groupIds]);
|
||||
};
|
||||
|
||||
const unrestrictedServerIds = (
|
||||
grant: McpEntitlementUpdate,
|
||||
servers: MCPServer[],
|
||||
toolsets: MCPToolset[],
|
||||
): ReadonlySet<string> => {
|
||||
const toolsetServerIds = new Set(
|
||||
toolsets
|
||||
.filter((toolset) => grant.mcp_toolsets.includes(toolset.toolset_id))
|
||||
.flatMap((toolset) => toolset.tools.map((tool) => tool.server_id)),
|
||||
);
|
||||
const grantedIds = directAndGroupServerIds(grant, servers);
|
||||
return new Set(
|
||||
servers
|
||||
.filter((server) => grantedIds.has(server.server_id) && !toolsetServerIds.has(server.server_id))
|
||||
.filter((server) => mcpAllowedToolsFor(server, grant.mcp_tool_permissions, servers) === undefined)
|
||||
.map((server) => server.server_id),
|
||||
);
|
||||
};
|
||||
|
||||
export const serversLosingToolLimits = (
|
||||
values: CustomerFormValues,
|
||||
existing: EndUser | null,
|
||||
servers: MCPServer[],
|
||||
toolsets: MCPToolset[],
|
||||
): readonly MCPServer[] => {
|
||||
const grant = extractMcpEntitlementUpdate(values, existing?.object_permission, servers, toolsets);
|
||||
if (!grant) return [];
|
||||
const stored = storedMcpGrant(existing);
|
||||
const retainedIds = directAndGroupServerIds(stored, servers);
|
||||
const directIds = new Set(
|
||||
grant.mcp_servers.flatMap((identifier) =>
|
||||
mcpServersForIdentifier(servers, identifier).map((server) => server.server_id),
|
||||
),
|
||||
);
|
||||
const unrestricted = unrestrictedServerIds(grant, servers, toolsets);
|
||||
const alreadyUnrestricted = unrestrictedServerIds(stored, servers, toolsets);
|
||||
const needsToolLimit = (server: MCPServer): boolean => {
|
||||
const protectedGrant = retainedIds.has(server.server_id) || directIds.has(server.server_id);
|
||||
return protectedGrant && unrestricted.has(server.server_id) && !alreadyUnrestricted.has(server.server_id);
|
||||
};
|
||||
return servers.filter(needsToolLimit);
|
||||
};
|
||||
|
|
@ -0,0 +1,137 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { EndUser } from "@/app/(dashboard)/hooks/customers/useCustomers";
|
||||
import { ALL_PROXY_MCP_SERVERS_SENTINEL, NO_MCP_SERVERS_SENTINEL } from "@/components/mcp_tools/constants";
|
||||
|
||||
import { customerMcpScope, getCustomerResourceCounts, type CustomerResourceCounts } from "./customerResources";
|
||||
|
||||
const emptyPermission: NonNullable<EndUser["object_permission"]> = {
|
||||
object_permission_id: "permission-id",
|
||||
agent_access_groups: [],
|
||||
agents: [],
|
||||
blocked_tools: [],
|
||||
mcp_servers: [],
|
||||
mcp_access_groups: [],
|
||||
mcp_toolsets: [],
|
||||
mcp_tool_permissions: {},
|
||||
models: [],
|
||||
search_tools: [],
|
||||
vector_stores: [],
|
||||
};
|
||||
|
||||
const customer: EndUser = { user_id: "customer-id", spend: 0, blocked: false };
|
||||
const noMcpCounts: CustomerResourceCounts = { models: "all", servers: 0, accessGroups: 0, toolsets: 0 };
|
||||
|
||||
describe("getCustomerResourceCounts", () => {
|
||||
it("counts distinct model and MCP grants", () => {
|
||||
const configured: EndUser = {
|
||||
...customer,
|
||||
models: ["model-a", "model-b", "model-a"],
|
||||
object_permission: {
|
||||
...emptyPermission,
|
||||
mcp_servers: ["server-a", "server-b", "server-a"],
|
||||
mcp_access_groups: ["group-a", "group-b", "group-a"],
|
||||
mcp_toolsets: ["toolset-a", "toolset-a"],
|
||||
},
|
||||
};
|
||||
const expectedCounts = { models: 2, servers: 2, accessGroups: 2, toolsets: 1 };
|
||||
expect(getCustomerResourceCounts(configured)).toEqual(expectedCounts);
|
||||
});
|
||||
|
||||
it.each([undefined, [], ["all-proxy-models"], ["model-a", "all-proxy-models"], ["*"]])(
|
||||
"labels unrestricted models as all (%j)",
|
||||
(models) => {
|
||||
expect(getCustomerResourceCounts({ ...customer, models }).models).toBe("all");
|
||||
},
|
||||
);
|
||||
|
||||
it("counts tool-permission servers without counting a server twice", () => {
|
||||
const configured = {
|
||||
...customer,
|
||||
object_permission: {
|
||||
...emptyPermission,
|
||||
mcp_servers: ["server-a"],
|
||||
mcp_tool_permissions: { "server-a": ["read", "write"], "server-b": ["read"] },
|
||||
},
|
||||
};
|
||||
expect(getCustomerResourceCounts(configured).servers).toBe(2);
|
||||
expect(customerMcpScope(configured.object_permission)).toBe("limited");
|
||||
});
|
||||
|
||||
it("excludes the unsupported all-MCP sentinel from customer server counts", () => {
|
||||
const configured = {
|
||||
...customer,
|
||||
object_permission: { ...emptyPermission, mcp_servers: [ALL_PROXY_MCP_SERVERS_SENTINEL, "server-a"] },
|
||||
};
|
||||
expect(getCustomerResourceCounts(configured).servers).toBe(1);
|
||||
expect(customerMcpScope(configured.object_permission)).toBe("limited");
|
||||
});
|
||||
|
||||
it("does not claim MCP access from unsupported all-MCP sentinel-only grants", () => {
|
||||
const configured = {
|
||||
...customer,
|
||||
object_permission: {
|
||||
...emptyPermission,
|
||||
mcp_servers: [ALL_PROXY_MCP_SERVERS_SENTINEL],
|
||||
mcp_tool_permissions: { [ALL_PROXY_MCP_SERVERS_SENTINEL]: ["read"] },
|
||||
},
|
||||
};
|
||||
expect(getCustomerResourceCounts(configured).servers).toBe(0);
|
||||
expect(customerMcpScope(configured.object_permission)).toBe("denied");
|
||||
});
|
||||
|
||||
it("zeroes every MCP grant when no-MCP overrides other grants", () => {
|
||||
const configured = {
|
||||
...customer,
|
||||
object_permission: {
|
||||
...emptyPermission,
|
||||
mcp_servers: [ALL_PROXY_MCP_SERVERS_SENTINEL, NO_MCP_SERVERS_SENTINEL, "server-a"],
|
||||
mcp_access_groups: ["group-a"],
|
||||
mcp_toolsets: ["toolset-a"],
|
||||
mcp_tool_permissions: { "server-a": ["read"] },
|
||||
},
|
||||
};
|
||||
expect(getCustomerResourceCounts(configured)).toEqual(noMcpCounts);
|
||||
expect(customerMcpScope(configured.object_permission)).toBe("denied");
|
||||
});
|
||||
|
||||
it("excludes permission-table models and resources not enforced for customers", () => {
|
||||
const configured = {
|
||||
...customer,
|
||||
models: ["customer-model"],
|
||||
object_permission: {
|
||||
...emptyPermission,
|
||||
models: ["permission-model-a", "permission-model-b"],
|
||||
vector_stores: ["store-id"],
|
||||
agents: ["agent-id"],
|
||||
agent_access_groups: ["agent-group-id"],
|
||||
search_tools: ["search-tool-id"],
|
||||
skills: ["skill-id"],
|
||||
},
|
||||
};
|
||||
expect(getCustomerResourceCounts(configured)).toEqual({ ...noMcpCounts, models: 1 });
|
||||
expect(customerMcpScope(configured.object_permission)).toBe("unrestricted");
|
||||
});
|
||||
});
|
||||
|
||||
describe("customerMcpScope", () => {
|
||||
it.each([
|
||||
undefined,
|
||||
null,
|
||||
emptyPermission,
|
||||
{ ...emptyPermission, mcp_servers: null, mcp_access_groups: null, mcp_toolsets: null },
|
||||
])("treats an empty customer-level grant as unrestricted (%j)", (object_permission) => {
|
||||
const unconfigured = { ...customer, object_permission };
|
||||
expect(customerMcpScope(object_permission)).toBe("unrestricted");
|
||||
expect(getCustomerResourceCounts(unconfigured)).toEqual(noMcpCounts);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ mcp_servers: ["server-id"] },
|
||||
{ mcp_access_groups: ["group-id"] },
|
||||
{ mcp_toolsets: ["toolset-id"] },
|
||||
{ mcp_tool_permissions: { "server-id": ["read"] } },
|
||||
])("recognizes a grant from each MCP source (%j)", (grant) => {
|
||||
expect(customerMcpScope({ ...emptyPermission, ...grant })).toBe("limited");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
import type { EndUser } from "@/app/(dashboard)/hooks/customers/useCustomers";
|
||||
import { ALL_PROXY_MCP_SERVERS_SENTINEL, NO_MCP_SERVERS_SENTINEL } from "@/components/mcp_tools/constants";
|
||||
|
||||
export type CustomerResourceCounts = {
|
||||
models: number | "all";
|
||||
servers: number;
|
||||
accessGroups: number;
|
||||
toolsets: number;
|
||||
};
|
||||
|
||||
export function getCustomerResourceCounts(
|
||||
customer: Pick<EndUser, "models" | "object_permission">,
|
||||
): CustomerResourceCounts {
|
||||
const models = customer.models ?? [];
|
||||
const permission = customer.object_permission;
|
||||
const servers = permission?.mcp_servers ?? [];
|
||||
const modelCount =
|
||||
models.length === 0 || models.includes("all-proxy-models") || models.includes("*") ? "all" : new Set(models).size;
|
||||
if (servers.includes(NO_MCP_SERVERS_SENTINEL)) {
|
||||
return { models: modelCount, servers: 0, accessGroups: 0, toolsets: 0 };
|
||||
}
|
||||
const serverIdentifiers = [...servers, ...Object.keys(permission?.mcp_tool_permissions ?? {})].filter(
|
||||
(server) => server !== ALL_PROXY_MCP_SERVERS_SENTINEL && server !== NO_MCP_SERVERS_SENTINEL,
|
||||
);
|
||||
return {
|
||||
models: modelCount,
|
||||
servers: new Set(serverIdentifiers).size,
|
||||
accessGroups: new Set(permission?.mcp_access_groups ?? []).size,
|
||||
toolsets: new Set(permission?.mcp_toolsets ?? []).size,
|
||||
};
|
||||
}
|
||||
|
||||
export type CustomerMcpScope = "limited" | "denied" | "unrestricted";
|
||||
|
||||
export function customerMcpScope(permission: EndUser["object_permission"]): CustomerMcpScope {
|
||||
const servers = permission?.mcp_servers ?? [];
|
||||
if (servers.includes(NO_MCP_SERVERS_SENTINEL)) return "denied";
|
||||
const counts = getCustomerResourceCounts({ object_permission: permission });
|
||||
if (counts.servers > 0 || counts.accessGroups > 0 || counts.toolsets > 0) return "limited";
|
||||
const onlyUnsupportedAll =
|
||||
servers.includes(ALL_PROXY_MCP_SERVERS_SENTINEL) ||
|
||||
Object.hasOwn(permission?.mcp_tool_permissions ?? {}, ALL_PROXY_MCP_SERVERS_SENTINEL);
|
||||
return onlyUnsupportedAll ? "denied" : "unrestricted";
|
||||
}
|
||||
|
|
@ -0,0 +1,440 @@
|
|||
import { renderHook } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { EndUser } from "@/app/(dashboard)/hooks/customers/useCustomers";
|
||||
import type { MCPServer, MCPToolset } from "@/components/mcp_tools/types";
|
||||
import { toCustomerFormValues } from "./customerPayload";
|
||||
import { useSaveCustomer } from "./useSaveCustomer";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
servers: vi.fn(),
|
||||
toolsets: vi.fn(),
|
||||
accessGroups: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/app/(dashboard)/hooks/mcpServers/useMCPServers", () => ({ useMCPServers: mocks.servers }));
|
||||
vi.mock("@/app/(dashboard)/hooks/mcpServers/useMCPToolsets", () => ({ useMCPToolsets: mocks.toolsets }));
|
||||
vi.mock("@/app/(dashboard)/hooks/mcpServers/useMCPAccessGroups", () => ({ useMCPAccessGroups: mocks.accessGroups }));
|
||||
vi.mock("@/app/(dashboard)/hooks/customers/useCustomerMutations", () => ({
|
||||
useCreateCustomer: () => ({ mutateAsync: mocks.create, isPending: false }),
|
||||
useUpdateCustomer: () => ({ mutateAsync: mocks.update, isPending: false }),
|
||||
}));
|
||||
|
||||
const permission = {
|
||||
object_permission_id: "permission-id",
|
||||
mcp_servers: ["server-id"],
|
||||
mcp_access_groups: [],
|
||||
mcp_toolsets: [],
|
||||
mcp_tool_permissions: { "server-id": ["read"] },
|
||||
agent_access_groups: [],
|
||||
agents: [],
|
||||
blocked_tools: [],
|
||||
models: [],
|
||||
search_tools: [],
|
||||
skills: [],
|
||||
vector_stores: [],
|
||||
};
|
||||
const customer: EndUser = {
|
||||
user_id: "customer-id",
|
||||
blocked: false,
|
||||
spend: 0,
|
||||
object_permission: permission,
|
||||
};
|
||||
const server: MCPServer = {
|
||||
server_id: "server-id",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
created_by: "admin",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
updated_by: "admin",
|
||||
};
|
||||
const toolset: MCPToolset = {
|
||||
toolset_id: "toolset-id",
|
||||
toolset_name: "Toolset",
|
||||
tools: [{ server_id: server.server_id, tool_name: "read" }],
|
||||
};
|
||||
const clearedGrant = { mcp_servers: [], mcp_access_groups: [], mcp_toolsets: [], mcp_tool_permissions: {} };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.servers.mockReturnValue({ data: undefined, isSuccess: false });
|
||||
mocks.toolsets.mockReturnValue({ data: undefined, isSuccess: false });
|
||||
mocks.accessGroups.mockReturnValue({ data: undefined, isSuccess: false });
|
||||
mocks.create.mockResolvedValue(customer);
|
||||
mocks.update.mockResolvedValue(customer);
|
||||
});
|
||||
|
||||
describe("useSaveCustomer", () => {
|
||||
it.each([
|
||||
{ state: "pending", data: undefined },
|
||||
{ state: "failed", data: [server] },
|
||||
])("refuses a server revocation while the catalog is $state", async ({ data }) => {
|
||||
mocks.servers.mockReturnValue({ data, isSuccess: false });
|
||||
const { result } = renderHook(() => useSaveCustomer(customer));
|
||||
const values = {
|
||||
...toCustomerFormValues(customer),
|
||||
mcp_servers_and_groups: { servers: [], accessGroups: [], toolsets: [] },
|
||||
};
|
||||
|
||||
await expect(result.current.saveCustomer(values)).rejects.toThrow("MCP catalogs must finish loading");
|
||||
expect(mocks.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("saves an ordinary edit without catalogs and leaves unknown stored grants untouched", async () => {
|
||||
const stored: EndUser = {
|
||||
...customer,
|
||||
object_permission: {
|
||||
...permission,
|
||||
mcp_servers: ["unknown-server"],
|
||||
mcp_tool_permissions: { "unknown-server": ["read"] },
|
||||
},
|
||||
};
|
||||
const { result } = renderHook(() => useSaveCustomer(stored));
|
||||
|
||||
await result.current.saveCustomer({ ...toCustomerFormValues(stored), alias: "New alias" });
|
||||
|
||||
expect(mocks.update).toHaveBeenCalledExactlyOnceWith({ user_id: stored.user_id, alias: "New alias" });
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
field: "MCP grant",
|
||||
current: {
|
||||
...customer,
|
||||
object_permission: { ...permission, mcp_servers: ["no-mcp-servers"], mcp_tool_permissions: {} },
|
||||
},
|
||||
},
|
||||
{ field: "models", current: { ...customer, models: ["model-a"] } },
|
||||
{ field: "region", current: { ...customer, allowed_model_region: "eu" as const } },
|
||||
{ field: "default model", current: { ...customer, default_model: "model-a" } },
|
||||
{ field: "budget link", current: { ...customer, budget_id: "new-budget" } },
|
||||
{
|
||||
field: "maximum budget",
|
||||
current: { ...customer, litellm_budget_table: { max_budget: 25, created_at: "2026-01-01T00:00:00Z" } },
|
||||
},
|
||||
])("refuses a deliberate permission edit after the stored $field changes", async ({ current }) => {
|
||||
mocks.servers.mockReturnValue({ data: [server], isSuccess: true });
|
||||
const { result, rerender } = renderHook(({ current }) => useSaveCustomer(current), {
|
||||
initialProps: { current: customer },
|
||||
});
|
||||
const values = {
|
||||
...toCustomerFormValues(customer),
|
||||
mcp_tool_permissions: { [server.server_id]: ["read", "write"] },
|
||||
};
|
||||
rerender({ current });
|
||||
|
||||
await expect(result.current.saveCustomer(values, customer)).rejects.toThrow("changed while editing");
|
||||
expect(mocks.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows deliberate permission edits after an unrelated refresh or reordered grants", async () => {
|
||||
mocks.servers.mockReturnValue({ data: [server], isSuccess: true });
|
||||
const baseline: EndUser = {
|
||||
...customer,
|
||||
models: ["model-a", "model-b"],
|
||||
object_permission: { ...permission, mcp_tool_permissions: { [server.server_id]: ["read", "write"] } },
|
||||
};
|
||||
const current: EndUser = {
|
||||
...baseline,
|
||||
alias: "Changed elsewhere",
|
||||
spend: 10,
|
||||
models: ["model-b", "model-a"],
|
||||
object_permission: { ...permission, mcp_tool_permissions: { [server.server_id]: ["write", "read"] } },
|
||||
};
|
||||
const { result } = renderHook(() => useSaveCustomer(current));
|
||||
|
||||
await result.current.saveCustomer(
|
||||
{
|
||||
...toCustomerFormValues(baseline),
|
||||
mcp_tool_permissions: { [server.server_id]: ["read"] },
|
||||
},
|
||||
baseline,
|
||||
);
|
||||
|
||||
expect(mocks.update).toHaveBeenCalledExactlyOnceWith({
|
||||
user_id: customer.user_id,
|
||||
object_permission: {
|
||||
...clearedGrant,
|
||||
mcp_servers: [server.server_id],
|
||||
mcp_tool_permissions: { [server.server_id]: ["read"] },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("revokes a direct server once its catalog loads, without requiring an unused toolset catalog", async () => {
|
||||
mocks.servers.mockReturnValue({ data: [server], isSuccess: true });
|
||||
const { result } = renderHook(() => useSaveCustomer(customer));
|
||||
const values = {
|
||||
...toCustomerFormValues(customer),
|
||||
mcp_servers_and_groups: { servers: [], accessGroups: [], toolsets: [] },
|
||||
};
|
||||
|
||||
await result.current.saveCustomer(values);
|
||||
|
||||
expect(mocks.update).toHaveBeenCalledExactlyOnceWith({
|
||||
user_id: customer.user_id,
|
||||
object_permission: clearedGrant,
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses a revocation with an empty final permission map until the server catalog loads", async () => {
|
||||
const stored: EndUser = { ...customer, object_permission: { ...permission, mcp_tool_permissions: {} } };
|
||||
const { result } = renderHook(() => useSaveCustomer(stored));
|
||||
const values = {
|
||||
...toCustomerFormValues(stored),
|
||||
mcp_servers_and_groups: { servers: [], accessGroups: [], toolsets: [] },
|
||||
};
|
||||
|
||||
await expect(result.current.saveCustomer(values)).rejects.toThrow("MCP catalogs must finish loading");
|
||||
expect(mocks.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses a new customer's server grant until the server catalog loads", async () => {
|
||||
const { result } = renderHook(() => useSaveCustomer(null));
|
||||
const values = {
|
||||
...toCustomerFormValues(null),
|
||||
user_id: customer.user_id,
|
||||
mcp_servers_and_groups: { servers: [server.server_id], accessGroups: [], toolsets: [] },
|
||||
};
|
||||
|
||||
await expect(result.current.saveCustomer(values)).rejects.toThrow("MCP catalogs must finish loading");
|
||||
expect(mocks.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ state: "pending", data: undefined },
|
||||
{ state: "failed", data: [toolset] },
|
||||
])("refuses a toolset revocation while its catalog is $state, then saves after it loads", async ({ data }) => {
|
||||
const stored: EndUser = {
|
||||
...customer,
|
||||
object_permission: { ...permission, mcp_servers: [], mcp_toolsets: [toolset.toolset_id] },
|
||||
};
|
||||
mocks.servers.mockReturnValue({ data: [server], isSuccess: true });
|
||||
mocks.toolsets.mockReturnValue({ data, isSuccess: false });
|
||||
const { result, rerender } = renderHook(() => useSaveCustomer(stored));
|
||||
const values = {
|
||||
...toCustomerFormValues(stored),
|
||||
mcp_servers_and_groups: { servers: [], accessGroups: [], toolsets: [] },
|
||||
};
|
||||
|
||||
await expect(result.current.saveCustomer(values)).rejects.toThrow("MCP catalogs must finish loading");
|
||||
expect(mocks.update).not.toHaveBeenCalled();
|
||||
mocks.toolsets.mockReturnValue({ data: [toolset], isSuccess: true });
|
||||
rerender();
|
||||
|
||||
await result.current.saveCustomer(values);
|
||||
|
||||
expect(mocks.update).toHaveBeenCalledExactlyOnceWith({
|
||||
user_id: stored.user_id,
|
||||
object_permission: clearedGrant,
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses a new direct server until it has an allowlist, then saves the allowlist", async () => {
|
||||
const added: MCPServer = { ...server, server_id: "added-server" };
|
||||
mocks.servers.mockReturnValue({ data: [server, added], isSuccess: true });
|
||||
const { result } = renderHook(() => useSaveCustomer(customer));
|
||||
const values = {
|
||||
...toCustomerFormValues(customer),
|
||||
mcp_servers_and_groups: { servers: [server.server_id, added.server_id], accessGroups: [], toolsets: [] },
|
||||
};
|
||||
|
||||
await expect(result.current.saveCustomer(values)).rejects.toThrow(
|
||||
"Choose allowed tools for each MCP server before saving",
|
||||
);
|
||||
expect(mocks.update).not.toHaveBeenCalled();
|
||||
|
||||
await result.current.saveCustomer({
|
||||
...values,
|
||||
mcp_tool_permissions: { ...values.mcp_tool_permissions, [added.server_id]: ["read"] },
|
||||
});
|
||||
|
||||
expect(mocks.update).toHaveBeenCalledExactlyOnceWith({
|
||||
user_id: customer.user_id,
|
||||
object_permission: {
|
||||
mcp_servers: [server.server_id, added.server_id],
|
||||
mcp_access_groups: [],
|
||||
mcp_toolsets: [],
|
||||
mcp_tool_permissions: { [server.server_id]: ["read"], [added.server_id]: ["read"] },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses removing the toolset that limits a stored direct server's tools until it has an allowlist", async () => {
|
||||
const stored: EndUser = {
|
||||
...customer,
|
||||
object_permission: { ...permission, mcp_toolsets: [toolset.toolset_id], mcp_tool_permissions: {} },
|
||||
};
|
||||
mocks.servers.mockReturnValue({ data: [server], isSuccess: true });
|
||||
mocks.toolsets.mockReturnValue({ data: [toolset], isSuccess: true });
|
||||
const { result } = renderHook(() => useSaveCustomer(stored));
|
||||
const values = {
|
||||
...toCustomerFormValues(stored),
|
||||
mcp_servers_and_groups: { servers: [server.server_id], accessGroups: [], toolsets: [] },
|
||||
};
|
||||
|
||||
await expect(result.current.saveCustomer(values)).rejects.toThrow(
|
||||
"Choose allowed tools for each MCP server before saving",
|
||||
);
|
||||
expect(mocks.update).not.toHaveBeenCalled();
|
||||
|
||||
await result.current.saveCustomer({ ...values, mcp_tool_permissions: { [server.server_id]: ["read"] } });
|
||||
|
||||
expect(mocks.update).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it.each([["group-a"], [{ name: "group-a" }]])(
|
||||
"preserves the last toolset ceiling on a retained group server with membership %j",
|
||||
async (membership) => {
|
||||
const stored: EndUser = {
|
||||
...customer,
|
||||
object_permission: {
|
||||
...permission,
|
||||
mcp_servers: [],
|
||||
mcp_access_groups: ["group-a"],
|
||||
mcp_toolsets: [toolset.toolset_id],
|
||||
mcp_tool_permissions: {},
|
||||
},
|
||||
};
|
||||
mocks.servers.mockReturnValue({ data: [{ ...server, mcp_access_groups: [membership] }], isSuccess: true });
|
||||
mocks.toolsets.mockReturnValue({ data: [toolset], isSuccess: true });
|
||||
mocks.accessGroups.mockReturnValue({ data: ["group-a"], isSuccess: true });
|
||||
const { result } = renderHook(() => useSaveCustomer(stored));
|
||||
const values = {
|
||||
...toCustomerFormValues(stored),
|
||||
mcp_servers_and_groups: { servers: [], accessGroups: ["group-a"], toolsets: [] },
|
||||
};
|
||||
|
||||
await expect(result.current.saveCustomer(values)).rejects.toThrow("Choose allowed tools");
|
||||
expect(mocks.update).not.toHaveBeenCalled();
|
||||
await result.current.saveCustomer({ ...values, mcp_tool_permissions: { [server.server_id]: ["read"] } });
|
||||
expect(mocks.update).toHaveBeenCalledExactlyOnceWith({
|
||||
user_id: stored.user_id,
|
||||
object_permission: {
|
||||
...clearedGrant,
|
||||
mcp_access_groups: ["group-a"],
|
||||
mcp_tool_permissions: { [server.server_id]: ["read"] },
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{
|
||||
scenario: "removing only the group",
|
||||
storedGroups: ["group-a"],
|
||||
storedToolsets: [toolset.toolset_id],
|
||||
servers: [],
|
||||
groups: [],
|
||||
toolsets: [toolset.toolset_id],
|
||||
},
|
||||
{
|
||||
scenario: "granting a new full-access group",
|
||||
storedGroups: [],
|
||||
storedToolsets: [],
|
||||
servers: [],
|
||||
groups: ["group-a"],
|
||||
toolsets: [],
|
||||
},
|
||||
{
|
||||
scenario: "denying every server",
|
||||
storedGroups: ["group-a"],
|
||||
storedToolsets: [toolset.toolset_id],
|
||||
servers: ["no-mcp-servers"],
|
||||
groups: ["group-a"],
|
||||
toolsets: [],
|
||||
},
|
||||
{
|
||||
scenario: "clearing all grants",
|
||||
storedGroups: ["group-a"],
|
||||
storedToolsets: [toolset.toolset_id],
|
||||
servers: [],
|
||||
groups: [],
|
||||
toolsets: [],
|
||||
},
|
||||
{
|
||||
scenario: "removing an unknown empty group",
|
||||
storedGroups: ["unknown-group"],
|
||||
storedToolsets: [toolset.toolset_id],
|
||||
servers: [],
|
||||
groups: [],
|
||||
toolsets: [toolset.toolset_id],
|
||||
},
|
||||
])(
|
||||
"allows $scenario without adding a tool map",
|
||||
async ({ storedGroups, storedToolsets, servers, groups, toolsets }) => {
|
||||
const stored: EndUser = {
|
||||
...customer,
|
||||
object_permission: {
|
||||
...permission,
|
||||
mcp_servers: [],
|
||||
mcp_access_groups: storedGroups,
|
||||
mcp_toolsets: storedToolsets,
|
||||
mcp_tool_permissions: {},
|
||||
},
|
||||
};
|
||||
mocks.servers.mockReturnValue({ data: [{ ...server, mcp_access_groups: ["group-a"] }], isSuccess: true });
|
||||
mocks.toolsets.mockReturnValue({ data: [toolset], isSuccess: true });
|
||||
mocks.accessGroups.mockReturnValue({ data: ["group-a"], isSuccess: true });
|
||||
const { result } = renderHook(() => useSaveCustomer(stored));
|
||||
await result.current.saveCustomer({
|
||||
...toCustomerFormValues(stored),
|
||||
mcp_servers_and_groups: { servers, accessGroups: groups, toolsets },
|
||||
});
|
||||
expect(mocks.update).toHaveBeenCalledExactlyOnceWith({
|
||||
user_id: stored.user_id,
|
||||
object_permission: {
|
||||
mcp_servers: servers,
|
||||
mcp_access_groups: groups,
|
||||
mcp_toolsets: toolsets,
|
||||
mcp_tool_permissions: {},
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("refuses a permission edit while a stored access group's catalog has failed, then saves after it loads", async () => {
|
||||
const added: MCPServer = { ...server, server_id: "added-server" };
|
||||
const stored: EndUser = { ...customer, object_permission: { ...permission, mcp_access_groups: ["group-a"] } };
|
||||
mocks.servers.mockReturnValue({ data: [server, added], isSuccess: true });
|
||||
mocks.accessGroups.mockReturnValue({ data: undefined, isSuccess: false });
|
||||
const { result, rerender } = renderHook(() => useSaveCustomer(stored));
|
||||
const values = {
|
||||
...toCustomerFormValues(stored),
|
||||
mcp_servers_and_groups: { servers: [server.server_id, added.server_id], accessGroups: ["group-a"], toolsets: [] },
|
||||
mcp_tool_permissions: { [server.server_id]: ["read"], [added.server_id]: ["read"] },
|
||||
};
|
||||
|
||||
await expect(result.current.saveCustomer(values)).rejects.toThrow("MCP catalogs must finish loading");
|
||||
expect(mocks.update).not.toHaveBeenCalled();
|
||||
mocks.accessGroups.mockReturnValue({ data: ["group-a"], isSuccess: true });
|
||||
rerender();
|
||||
|
||||
await result.current.saveCustomer(values);
|
||||
|
||||
expect(mocks.update).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ case: "an unrestricted stored server", stored: ["added-server"], toolsetServer: "server-id" },
|
||||
{ case: "a server its selected toolset covers", stored: [], toolsetServer: "added-server" },
|
||||
])("saves $case without an allowlist", async ({ stored, toolsetServer }) => {
|
||||
const added: MCPServer = { ...server, server_id: "added-server" };
|
||||
const addedToolset: MCPToolset = { ...toolset, tools: [{ server_id: toolsetServer, tool_name: "read" }] };
|
||||
const existing: EndUser = {
|
||||
...customer,
|
||||
object_permission: { ...permission, mcp_servers: stored, mcp_tool_permissions: {} },
|
||||
};
|
||||
mocks.servers.mockReturnValue({ data: [server, added], isSuccess: true });
|
||||
mocks.toolsets.mockReturnValue({ data: [addedToolset], isSuccess: true });
|
||||
const { result } = renderHook(() => useSaveCustomer(existing));
|
||||
|
||||
await result.current.saveCustomer({
|
||||
...toCustomerFormValues(existing),
|
||||
mcp_servers_and_groups: { servers: [added.server_id], accessGroups: [], toolsets: [toolset.toolset_id] },
|
||||
});
|
||||
|
||||
expect(mocks.update).toHaveBeenCalledExactlyOnceWith(
|
||||
expect.objectContaining({ object_permission: expect.objectContaining({ mcp_toolsets: [toolset.toolset_id] }) }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
import type { EndUser } from "@/app/(dashboard)/hooks/customers/useCustomers";
|
||||
import { useCreateCustomer, useUpdateCustomer } from "@/app/(dashboard)/hooks/customers/useCustomerMutations";
|
||||
import { useMCPAccessGroups } from "@/app/(dashboard)/hooks/mcpServers/useMCPAccessGroups";
|
||||
import { useMCPServers } from "@/app/(dashboard)/hooks/mcpServers/useMCPServers";
|
||||
import { useMCPToolsets } from "@/app/(dashboard)/hooks/mcpServers/useMCPToolsets";
|
||||
import {
|
||||
buildCustomerPayload,
|
||||
hasCustomerEditConflict,
|
||||
serversLosingToolLimits,
|
||||
type CustomerFormValues,
|
||||
} from "./customerPayload";
|
||||
|
||||
export const useSaveCustomer = (existing: EndUser | null) => {
|
||||
const { data: servers = [], isSuccess: serversReady } = useMCPServers();
|
||||
const { data: toolsets = [], isSuccess: toolsetsReady } = useMCPToolsets();
|
||||
const { isSuccess: accessGroupsReady } = useMCPAccessGroups();
|
||||
const create = useCreateCustomer();
|
||||
const update = useUpdateCustomer();
|
||||
const saveCustomer = (values: CustomerFormValues, baseline: EndUser | null = existing) => {
|
||||
const payload = buildCustomerPayload(values, baseline, servers, toolsets);
|
||||
if (hasCustomerEditConflict(payload, baseline, existing)) {
|
||||
return Promise.reject(
|
||||
new Error("Customer permissions or budget changed while editing. Cancel and reopen Settings before saving"),
|
||||
);
|
||||
}
|
||||
if (!payload.object_permission) return existing ? update.mutateAsync(payload) : create.mutateAsync(payload);
|
||||
const selection = values.mcp_servers_and_groups;
|
||||
const stored = baseline?.object_permission;
|
||||
const catalogs = [
|
||||
{ needed: true, ready: serversReady },
|
||||
{ needed: selection.toolsets.length > 0 || (stored?.mcp_toolsets?.length ?? 0) > 0, ready: toolsetsReady },
|
||||
{
|
||||
needed: selection.accessGroups.length > 0 || (stored?.mcp_access_groups?.length ?? 0) > 0,
|
||||
ready: accessGroupsReady,
|
||||
},
|
||||
];
|
||||
if (catalogs.some(({ needed, ready }) => needed && !ready)) {
|
||||
return Promise.reject(new Error("MCP catalogs must finish loading before permission changes can be saved"));
|
||||
}
|
||||
if (serversLosingToolLimits(values, baseline, servers, toolsets).length > 0) {
|
||||
return Promise.reject(
|
||||
new Error(
|
||||
"Choose allowed tools for each MCP server before saving, or it gets every tool including delete tools",
|
||||
),
|
||||
);
|
||||
}
|
||||
return existing ? update.mutateAsync(payload) : create.mutateAsync(payload);
|
||||
};
|
||||
return { saveCustomer, isSaving: create.isPending || update.isPending };
|
||||
};
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import CustomersPanel from "./_components/CustomersPanel";
|
||||
|
||||
export default function CustomersPage() {
|
||||
return <CustomersPanel />;
|
||||
}
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { apiClient } from "@/components/networking";
|
||||
import { createCustomer, deleteCustomers, setCustomersBlockedState, updateCustomer } from "./customerApi";
|
||||
|
||||
vi.mock("@/components/networking", () => ({ apiClient: { post: vi.fn() } }));
|
||||
|
||||
const post = vi.mocked(apiClient.post);
|
||||
|
||||
describe("customer API", () => {
|
||||
beforeEach(() => {
|
||||
post.mockReset();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ name: "create", action: createCustomer, path: "/customer/new" },
|
||||
{ name: "update", action: updateCustomer, path: "/customer/update" },
|
||||
])("posts the $name payload and returns the customer", async ({ action, path }) => {
|
||||
const body = { user_id: "customer-id", alias: "Alias" };
|
||||
const response = { ...body, blocked: false, spend: 0 };
|
||||
post.mockResolvedValue(response);
|
||||
|
||||
await expect(action("access-token", body)).resolves.toEqual(response);
|
||||
expect(post).toHaveBeenCalledWith(path, {
|
||||
accessToken: "access-token",
|
||||
body,
|
||||
});
|
||||
});
|
||||
|
||||
it("posts the customer IDs to delete", async () => {
|
||||
const userIds = ["customer-id", "another-id"];
|
||||
const response = {
|
||||
deleted_customers: userIds.length,
|
||||
message: "Customers deleted",
|
||||
};
|
||||
post.mockResolvedValue(response);
|
||||
|
||||
await expect(deleteCustomers("access-token", userIds)).resolves.toEqual(response);
|
||||
expect(post).toHaveBeenCalledWith("/customer/delete", {
|
||||
accessToken: "access-token",
|
||||
body: { user_ids: userIds },
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
blocked: true,
|
||||
path: "/customer/block",
|
||||
response: { blocked_users: [{ user_id: "customer-id", blocked: true }] },
|
||||
},
|
||||
{
|
||||
blocked: false,
|
||||
path: "/customer/unblock",
|
||||
response: { blocked_users: ["remaining-id"] },
|
||||
},
|
||||
])("posts all selected customer IDs to $path", async ({ blocked, path, response }) => {
|
||||
const userIds = ["customer-id", "another-id"];
|
||||
post.mockResolvedValue(response);
|
||||
|
||||
await expect(setCustomersBlockedState("access-token", { userIds, blocked })).resolves.toEqual(response);
|
||||
expect(post).toHaveBeenCalledExactlyOnceWith(path, {
|
||||
accessToken: "access-token",
|
||||
body: { user_ids: userIds },
|
||||
});
|
||||
});
|
||||
|
||||
it.each([true, false])("propagates bulk action failures when blocked is %s", async (blocked) => {
|
||||
const error = new Error(blocked ? "Customer action failed" : "Blocked user check was never set");
|
||||
post.mockRejectedValue(error);
|
||||
|
||||
await expect(
|
||||
setCustomersBlockedState("access-token", {
|
||||
userIds: ["customer-id", "another-id"],
|
||||
blocked,
|
||||
}),
|
||||
).rejects.toBe(error);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
import { apiClient } from "@/components/networking";
|
||||
import type { components } from "@/lib/http/schema";
|
||||
import type { EndUser } from "./useCustomers";
|
||||
|
||||
type Schemas = components["schemas"];
|
||||
|
||||
export type CreateCustomerRequest = Partial<Schemas["NewCustomerRequest"]> & {
|
||||
user_id: string;
|
||||
};
|
||||
export type UpdateCustomerRequest = Partial<Schemas["UpdateCustomerRequest"]> & { user_id: string };
|
||||
export type CustomersBlockedStateInput = {
|
||||
userIds: string[];
|
||||
blocked: boolean;
|
||||
};
|
||||
|
||||
export const createCustomer = (accessToken: string, body: CreateCustomerRequest): Promise<EndUser> =>
|
||||
apiClient.post<EndUser>("/customer/new", { accessToken, body });
|
||||
|
||||
export const updateCustomer = (accessToken: string, body: UpdateCustomerRequest): Promise<EndUser> =>
|
||||
apiClient.post<EndUser>("/customer/update", { accessToken, body });
|
||||
|
||||
export const deleteCustomers = (accessToken: string, userIds: string[]): Promise<Schemas["DeleteCustomersResponse"]> =>
|
||||
apiClient.post<Schemas["DeleteCustomersResponse"]>("/customer/delete", {
|
||||
accessToken,
|
||||
body: { user_ids: userIds },
|
||||
});
|
||||
|
||||
export const setCustomersBlockedState = (
|
||||
accessToken: string,
|
||||
{ userIds, blocked }: CustomersBlockedStateInput,
|
||||
): Promise<Schemas["BlockUsersResponse"] | Schemas["UnblockUsersResponse"]> =>
|
||||
apiClient.post<Schemas["BlockUsersResponse"] | Schemas["UnblockUsersResponse"]>(
|
||||
blocked ? "/customer/block" : "/customer/unblock",
|
||||
{
|
||||
accessToken,
|
||||
body: { user_ids: userIds },
|
||||
},
|
||||
);
|
||||
|
|
@ -0,0 +1,198 @@
|
|||
import React, { type ReactNode } from "react";
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { apiClient } from "@/components/networking";
|
||||
import { $api } from "@/lib/http/api";
|
||||
import {
|
||||
useCreateCustomer,
|
||||
useCustomer,
|
||||
useDeleteCustomers,
|
||||
useSetCustomersBlockedState,
|
||||
useUpdateCustomer,
|
||||
} from "./useCustomerMutations";
|
||||
|
||||
const authorized = vi.hoisted(() => vi.fn());
|
||||
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
|
||||
default: authorized,
|
||||
}));
|
||||
vi.mock("@/components/networking", () => ({ apiClient: { post: vi.fn() } }));
|
||||
vi.mock("@/lib/http/api", () => ({ $api: { useQuery: vi.fn() } }));
|
||||
|
||||
const post = vi.mocked(apiClient.post);
|
||||
const query = vi.mocked($api.useQuery);
|
||||
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
});
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
return { queryClient, wrapper };
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
authorized.mockReturnValue({
|
||||
accessToken: "access-token",
|
||||
userRole: "Admin",
|
||||
});
|
||||
});
|
||||
|
||||
describe("useCustomer", () => {
|
||||
it("fetches the requested customer with an admin access token", () => {
|
||||
renderHook(() => useCustomer("customer-id"));
|
||||
expect(query).toHaveBeenCalledWith(
|
||||
"get",
|
||||
"/customer/info",
|
||||
{ params: { query: { end_user_id: "customer-id" } } },
|
||||
{ enabled: true },
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
role: "Admin Viewer",
|
||||
accessToken: "access-token",
|
||||
id: "customer-id",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
role: "Internal User",
|
||||
accessToken: "access-token",
|
||||
id: "customer-id",
|
||||
enabled: false,
|
||||
},
|
||||
{ role: "Admin", accessToken: null, id: "customer-id", enabled: false },
|
||||
{ role: "Admin", accessToken: "access-token", id: null, enabled: false },
|
||||
])("gates the query for $role with customer $id", ({ role, accessToken, id, enabled }) => {
|
||||
authorized.mockReturnValue({ accessToken, userRole: role });
|
||||
renderHook(() => useCustomer(id));
|
||||
expect(query).toHaveBeenCalledWith(
|
||||
"get",
|
||||
"/customer/info",
|
||||
{ params: { query: { end_user_id: id ?? "" } } },
|
||||
{ enabled },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("customer mutations", () => {
|
||||
it("invalidates both list and detail prefixes after a successful update", async () => {
|
||||
const { queryClient, wrapper } = createWrapper();
|
||||
const listKey = ["get", "/customer/list", {}];
|
||||
const detailKey = ["get", "/customer/info", { params: { query: { end_user_id: "customer-id" } } }];
|
||||
const otherDetailKey = ["get", "/customer/info", { params: { query: { end_user_id: "another-id" } } }];
|
||||
const unrelatedKey = ["get", "/key/list", {}];
|
||||
queryClient.setQueryData(listKey, []);
|
||||
queryClient.setQueryData(detailKey, {});
|
||||
queryClient.setQueryData(otherDetailKey, {});
|
||||
queryClient.setQueryData(unrelatedKey, []);
|
||||
post.mockResolvedValue({ user_id: "customer-id" });
|
||||
const { result } = renderHook(() => useUpdateCustomer(), { wrapper });
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync({
|
||||
user_id: "customer-id",
|
||||
alias: "Alias",
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
expect(queryClient.getQueryState(listKey)?.isInvalidated).toBe(true);
|
||||
expect(queryClient.getQueryState(detailKey)?.isInvalidated).toBe(true);
|
||||
expect(queryClient.getQueryState(otherDetailKey)?.isInvalidated).toBe(true);
|
||||
expect(queryClient.getQueryState(unrelatedKey)?.isInvalidated).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ name: "create", useHook: useCreateCustomer },
|
||||
{ name: "update", useHook: useUpdateCustomer },
|
||||
])("rejects $name without an access token", async ({ useHook }) => {
|
||||
authorized.mockReturnValue({ accessToken: null, userRole: "Admin" });
|
||||
const { wrapper } = createWrapper();
|
||||
const { result } = renderHook(useHook, { wrapper });
|
||||
|
||||
await act(async () => {
|
||||
await expect(result.current.mutateAsync({ user_id: "customer-id" })).rejects.toThrow("Access token is required");
|
||||
});
|
||||
expect(post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects deletion without an access token", async () => {
|
||||
authorized.mockReturnValue({ accessToken: null, userRole: "Admin" });
|
||||
const { wrapper } = createWrapper();
|
||||
const { result } = renderHook(useDeleteCustomers, { wrapper });
|
||||
|
||||
await act(async () => {
|
||||
await expect(result.current.mutateAsync(["customer-id"])).rejects.toThrow("Access token is required");
|
||||
});
|
||||
expect(post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([true, false])("invalidates customer data after bulk blocked state becomes %s", async (blocked) => {
|
||||
const { queryClient, wrapper } = createWrapper();
|
||||
const userIds = ["customer-id", "another-id"];
|
||||
const listKey = ["get", "/customer/list", {}];
|
||||
const detailKeys = userIds.map((id) => ["get", "/customer/info", { params: { query: { end_user_id: id } } }]);
|
||||
queryClient.setQueryData(listKey, []);
|
||||
detailKeys.forEach((key) => queryClient.setQueryData(key, {}));
|
||||
const response = blocked
|
||||
? {
|
||||
blocked_users: userIds.map((user_id) => ({
|
||||
user_id,
|
||||
blocked: true,
|
||||
})),
|
||||
}
|
||||
: { blocked_users: ["remaining-id"] };
|
||||
post.mockResolvedValue(response);
|
||||
const { result } = renderHook(useSetCustomersBlockedState, { wrapper });
|
||||
|
||||
await act(async () => {
|
||||
await expect(result.current.mutateAsync({ userIds, blocked })).resolves.toEqual(response);
|
||||
});
|
||||
|
||||
expect(post).toHaveBeenCalledExactlyOnceWith(blocked ? "/customer/block" : "/customer/unblock", {
|
||||
accessToken: "access-token",
|
||||
body: { user_ids: userIds },
|
||||
});
|
||||
expect(queryClient.getQueryState(listKey)?.isInvalidated).toBe(true);
|
||||
detailKeys.forEach((key) => expect(queryClient.getQueryState(key)?.isInvalidated).toBe(true));
|
||||
});
|
||||
|
||||
it.each([true, false])("retains customer cache data when a bulk action fails with blocked %s", async (blocked) => {
|
||||
const { queryClient, wrapper } = createWrapper();
|
||||
const listKey = ["get", "/customer/list", {}];
|
||||
const detailKey = ["get", "/customer/info", { params: { query: { end_user_id: "customer-id" } } }];
|
||||
const customer = { user_id: "customer-id", blocked: !blocked };
|
||||
queryClient.setQueryData(listKey, [customer]);
|
||||
queryClient.setQueryData(detailKey, customer);
|
||||
const error = new Error(blocked ? "Bulk block failed" : "Blocked user check was never set");
|
||||
post.mockRejectedValue(error);
|
||||
const { result } = renderHook(useSetCustomersBlockedState, { wrapper });
|
||||
|
||||
await act(async () => {
|
||||
await expect(result.current.mutateAsync({ userIds: ["customer-id"], blocked })).rejects.toBe(error);
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.isError).toBe(true));
|
||||
expect(queryClient.getQueryData(listKey)).toEqual([customer]);
|
||||
expect(queryClient.getQueryData(detailKey)).toEqual(customer);
|
||||
expect(queryClient.getQueryState(listKey)?.isInvalidated).toBe(false);
|
||||
expect(queryClient.getQueryState(detailKey)?.isInvalidated).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects bulk blocked state changes without an access token", async () => {
|
||||
authorized.mockReturnValue({ accessToken: null, userRole: "Admin" });
|
||||
const { wrapper } = createWrapper();
|
||||
const { result } = renderHook(useSetCustomersBlockedState, { wrapper });
|
||||
|
||||
await act(async () => {
|
||||
await expect(result.current.mutateAsync({ userIds: ["customer-id"], blocked: true })).rejects.toThrow(
|
||||
"Access token is required",
|
||||
);
|
||||
});
|
||||
expect(post).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import { $api } from "@/lib/http/api";
|
||||
import { all_admin_roles } from "@/utils/roles";
|
||||
import { createCustomer, deleteCustomers, setCustomersBlockedState, updateCustomer } from "./customerApi";
|
||||
|
||||
export const useCustomer = (id: string | null) => {
|
||||
const { accessToken, userRole } = useAuthorized();
|
||||
return $api.useQuery(
|
||||
"get",
|
||||
"/customer/info",
|
||||
{ params: { query: { end_user_id: id ?? "" } } },
|
||||
{ enabled: Boolean(accessToken && id) && all_admin_roles.includes(userRole ?? "") },
|
||||
);
|
||||
};
|
||||
|
||||
const useCustomerMutation = <TInput, TResult>(action: (accessToken: string, input: TInput) => Promise<TResult>) => {
|
||||
const { accessToken } = useAuthorized();
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<TResult, Error, TInput>({
|
||||
mutationFn: (input) => {
|
||||
if (!accessToken) throw new Error("Access token is required");
|
||||
return action(accessToken, input);
|
||||
},
|
||||
onSuccess: () =>
|
||||
Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["get", "/customer/list"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["get", "/customer/info"] }),
|
||||
]),
|
||||
});
|
||||
};
|
||||
|
||||
export const useCreateCustomer = () => useCustomerMutation(createCustomer);
|
||||
export const useUpdateCustomer = () => useCustomerMutation(updateCustomer);
|
||||
export const useDeleteCustomers = () => useCustomerMutation(deleteCustomers);
|
||||
export const useSetCustomersBlockedState = () => useCustomerMutation(setCustomersBlockedState);
|
||||
|
|
@ -42,6 +42,7 @@ const LEGACY_PAGE_ROUTES: ReadonlyMap<string, string> = new Map(
|
|||
agents: "agents",
|
||||
"router-settings": "router-settings",
|
||||
users: "users",
|
||||
customers: "customers",
|
||||
teams: "teams",
|
||||
organizations: "organizations",
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import userEvent from "@testing-library/user-event";
|
|||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import UserInfoView from "./user_info_view";
|
||||
import { extractMcpEntitlement } from "@/components/mcp_server_management/mcpEntitlement";
|
||||
import type { MCPServer } from "@/components/mcp_tools/types";
|
||||
|
||||
const mockTeamMemberAddCall = vi.fn();
|
||||
const mockTeamMemberDeleteCall = vi.fn();
|
||||
|
|
@ -12,6 +13,7 @@ const mockTeamInfoCall = vi.fn();
|
|||
const mockUserUpdateUserCall = vi.fn();
|
||||
const mockFetchMCPServers = vi.fn();
|
||||
const mockListMCPTools = vi.fn();
|
||||
const mockMCPServerCatalog = vi.fn();
|
||||
|
||||
const MCP_SERVER = { server_id: "srv-1", server_name: "GitHub MCP", alias: "GitHub MCP" };
|
||||
|
||||
|
|
@ -69,7 +71,7 @@ vi.mock("@/components/networking", () => {
|
|||
});
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/mcpServers/useMCPServers", () => ({
|
||||
useMCPServers: () => ({ data: [MCP_SERVER], isLoading: false }),
|
||||
useMCPServers: () => mockMCPServerCatalog(),
|
||||
}));
|
||||
vi.mock("@/app/(dashboard)/hooks/mcpServers/useMCPAccessGroups", () => ({
|
||||
useMCPAccessGroups: () => ({ data: ["dev-group"], isLoading: false }),
|
||||
|
|
@ -107,6 +109,7 @@ describe("UserInfoView", () => {
|
|||
mockTeamMemberDeleteCall.mockResolvedValue({});
|
||||
mockUserUpdateUserCall.mockResolvedValue({});
|
||||
mockFetchMCPServers.mockResolvedValue([MCP_SERVER]);
|
||||
mockMCPServerCatalog.mockReturnValue({ data: [MCP_SERVER], isLoading: false });
|
||||
mockListMCPTools.mockResolvedValue({ tools: [{ name: "list_issues", description: "List issues" }] });
|
||||
});
|
||||
|
||||
|
|
@ -330,6 +333,43 @@ describe("UserInfoView", () => {
|
|||
});
|
||||
|
||||
describe("MCP permissions", () => {
|
||||
it.each([
|
||||
{ field: "user_email", label: "Email", value: "renamed@example.com", catalogLoaded: true },
|
||||
{ field: "user_alias", label: "User Alias", value: "Renamed user", catalogLoaded: false },
|
||||
])(
|
||||
"preserves shared-alias grants when only $field changes with catalogLoaded=$catalogLoaded",
|
||||
async ({ field, label, value, catalogLoaded }) => {
|
||||
const servers = [
|
||||
{ server_id: "server-a", server_name: "Server A", alias: "shared" },
|
||||
{ server_id: "server-b", server_name: "Server B", alias: "shared" },
|
||||
];
|
||||
const permission = {
|
||||
mcp_servers: ["server-a"],
|
||||
mcp_access_groups: [],
|
||||
mcp_toolsets: [],
|
||||
mcp_tool_permissions: { shared: ["list_issues"] },
|
||||
};
|
||||
mockUserGetInfoV2.mockResolvedValue({ ...MOCK_USER_DATA, object_permission: permission });
|
||||
mockMCPServerCatalog.mockReturnValue({ data: catalogLoaded ? servers : [], isLoading: !catalogLoaded });
|
||||
if (catalogLoaded) mockFetchMCPServers.mockResolvedValue(servers);
|
||||
else mockFetchMCPServers.mockReturnValue(new Promise<MCPServer[]>(() => {}));
|
||||
render(<UserInfoView {...defaultProps} userRole="proxy_admin" initialTab={1} startInEditMode />);
|
||||
|
||||
fireEvent.change(await screen.findByRole("textbox", { name: label, exact: true }), { target: { value } });
|
||||
await userEvent.click(screen.getByRole("button", { name: "Save Changes", exact: true }));
|
||||
await waitFor(() => expect(mockUserUpdateUserCall).toHaveBeenCalledOnce());
|
||||
|
||||
const [, payload] = mockUserUpdateUserCall.mock.calls[0];
|
||||
expect(payload[field]).toBe(value);
|
||||
expect(payload).not.toHaveProperty("object_permission");
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Edit Settings", exact: true }));
|
||||
expect(await screen.findByRole("textbox", { name: label, exact: true })).toHaveValue(value);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Save Changes", exact: true }));
|
||||
await waitFor(() => expect(mockUserUpdateUserCall).toHaveBeenCalledTimes(2));
|
||||
expect(mockUserUpdateUserCall.mock.calls[1][1]).not.toHaveProperty("object_permission");
|
||||
},
|
||||
);
|
||||
|
||||
it("should render the user's MCP entitlements in read mode", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<UserInfoView {...defaultProps} userRole="proxy_admin" initialTab={1} />);
|
||||
|
|
@ -347,7 +387,7 @@ describe("UserInfoView", () => {
|
|||
expect(await screen.findByText("list_issues")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should nest MCP entitlements under object_permission when an admin saves", async () => {
|
||||
it("omits unchanged MCP entitlements when an admin saves", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<UserInfoView {...defaultProps} userRole="proxy_admin" initialTab={1} startInEditMode />);
|
||||
|
||||
|
|
@ -362,13 +402,7 @@ describe("UserInfoView", () => {
|
|||
expect(token).toBe("test-token");
|
||||
expect(roleArg).toBeNull();
|
||||
expect(payload.user_id).toBe("user-123");
|
||||
const expectedObjectPermission = {
|
||||
mcp_servers: ["srv-1"],
|
||||
mcp_access_groups: ["dev-group"],
|
||||
mcp_toolsets: [],
|
||||
mcp_tool_permissions: { "srv-1": ["list_issues"] },
|
||||
};
|
||||
expect(payload.object_permission).toEqual(expectedObjectPermission);
|
||||
expect(payload).not.toHaveProperty("object_permission");
|
||||
expect(payload).not.toHaveProperty("mcp_servers_and_groups");
|
||||
expect(payload).not.toHaveProperty("mcp_tool_permissions");
|
||||
expect(payload).not.toHaveProperty("mcp_servers");
|
||||
|
|
@ -417,9 +451,35 @@ describe("UserInfoView", () => {
|
|||
});
|
||||
|
||||
const [, payload] = mockUserUpdateUserCall.mock.calls[0];
|
||||
expect(payload.object_permission.mcp_tool_permissions).toEqual({
|
||||
"srv-1": ["list_issues"],
|
||||
"srv-via-group": ["read_only"],
|
||||
expect(payload).not.toHaveProperty("object_permission");
|
||||
});
|
||||
|
||||
it("retains an independent tool-only server grant when another server's tools are edited", async () => {
|
||||
const user = userEvent.setup();
|
||||
const servers = [MCP_SERVER, { server_id: "server-b", server_name: "Server B" }];
|
||||
const permission = {
|
||||
mcp_servers: ["srv-1"],
|
||||
mcp_access_groups: [],
|
||||
mcp_toolsets: [],
|
||||
mcp_tool_permissions: { "srv-1": ["read_direct"], "server-b": ["read_independent"] },
|
||||
};
|
||||
mockUserGetInfoV2.mockResolvedValue({ ...MOCK_USER_DATA, object_permission: permission });
|
||||
mockFetchMCPServers.mockResolvedValue(servers);
|
||||
mockMCPServerCatalog.mockReturnValue({ data: servers, isLoading: false });
|
||||
mockListMCPTools.mockImplementation((_token: string, serverId: string) =>
|
||||
Promise.resolve({ tools: [{ name: serverId === "srv-1" ? "read_direct" : "read_independent" }] }),
|
||||
);
|
||||
render(<UserInfoView {...defaultProps} userRole="proxy_admin" initialTab={1} startInEditMode />);
|
||||
|
||||
await user.click((await screen.findAllByRole("radio", { name: "Flat List" }))[0]);
|
||||
await user.click(await screen.findByRole("checkbox", { name: "read_direct", exact: true }));
|
||||
await user.click(screen.getByRole("button", { name: "Save Changes", exact: true }));
|
||||
await waitFor(() => expect(mockUserUpdateUserCall).toHaveBeenCalledOnce());
|
||||
|
||||
const [, payload] = mockUserUpdateUserCall.mock.calls[0];
|
||||
expect(payload.object_permission).toEqual({
|
||||
...permission,
|
||||
mcp_tool_permissions: { "srv-1": [], "server-b": ["read_independent"] },
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -442,6 +502,12 @@ describe("UserInfoView", () => {
|
|||
});
|
||||
|
||||
describe("extractMcpEntitlement", () => {
|
||||
const auditFields = {
|
||||
created_at: "2026-01-01",
|
||||
created_by: "test-admin",
|
||||
updated_at: "2026-01-01",
|
||||
updated_by: "test-admin",
|
||||
};
|
||||
const CATALOG = [
|
||||
{ server_id: "srv-1", server_name: "deploy_tracker", alias: "deploy" },
|
||||
{ server_id: "srv-2", server_name: "issue_tracker", alias: null },
|
||||
|
|
@ -495,6 +561,29 @@ describe("extractMcpEntitlement", () => {
|
|||
expect(result?.mcp_tool_permissions).toEqual({});
|
||||
});
|
||||
|
||||
it.each(["server_name", "alias"])("revokes an ID-keyed grant despite another server's matching %s", (field) => {
|
||||
const catalog: MCPServer[] = [
|
||||
{ ...auditFields, server_id: "srv-1", server_name: "First Server" },
|
||||
{ ...auditFields, server_id: "srv-2", server_name: "Second Server", [field]: "srv-1" },
|
||||
];
|
||||
const result = extractMcpEntitlement(form({ servers: ["srv-2"] }, { "srv-1": ["read"] }), catalog);
|
||||
|
||||
expect(result?.mcp_tool_permissions).toEqual({});
|
||||
});
|
||||
|
||||
it.each(["server_name", "alias"])("resolves a selected ID before another server's matching %s", (field) => {
|
||||
const catalog: MCPServer[] = [
|
||||
{ ...auditFields, server_id: "srv-1", server_name: "First Server" },
|
||||
{ ...auditFields, server_id: "srv-2", server_name: "Second Server", [field]: "srv-1" },
|
||||
];
|
||||
const result = extractMcpEntitlement(
|
||||
form({ servers: ["srv-1"] }, { "srv-1": ["read"], "srv-2": ["write"] }),
|
||||
catalog,
|
||||
);
|
||||
|
||||
expect(result?.mcp_tool_permissions).toEqual({ "srv-1": ["read"] });
|
||||
});
|
||||
|
||||
it("prunes nothing when the server catalog has not loaded", () => {
|
||||
// Every key is unresolvable without the catalog, and pruning while under-informed is the
|
||||
// direction that widens.
|
||||
|
|
@ -507,22 +596,30 @@ describe("extractMcpEntitlement", () => {
|
|||
expect(result?.mcp_tool_permissions).toEqual({ "srv-deleted": ["read"] });
|
||||
});
|
||||
|
||||
it.each([
|
||||
["selected server first", ["srv-shared-a", "srv-shared-b"]],
|
||||
["deselected server first", ["srv-shared-b", "srv-shared-a"]],
|
||||
])("keeps a shared-name allowlist while any server it names is granted (%s)", (_label, order) => {
|
||||
// Names are not unique and the gateway unions a name key into EVERY server answering to it, so
|
||||
// this one entry restricts both. Resolving to the first match would drop it whenever the catalog
|
||||
// happened to return the deselected server first, stripping the restriction from the one still
|
||||
// granted, which is a widening that reproduces on one deployment and not another.
|
||||
const catalog = [
|
||||
{ server_id: "srv-shared-a", server_name: "shared", alias: null },
|
||||
{ server_id: "srv-shared-b", server_name: "shared", alias: null },
|
||||
] as any;
|
||||
const ordered = order.map((id) => catalog.find((server: any) => server.server_id === id));
|
||||
it.each([false, true])("limits a shared alias to retained servers regardless of catalog order (%s)", (reverse) => {
|
||||
const catalog: MCPServer[] = [
|
||||
{ ...auditFields, server_id: "srv-shared-a", server_name: "kept", alias: "shared" },
|
||||
{ ...auditFields, server_id: "srv-shared-b", server_name: "removed", alias: "shared" },
|
||||
];
|
||||
const ordered = reverse ? catalog.toReversed() : catalog;
|
||||
const permissions = {
|
||||
"srv-shared-a": ["read"],
|
||||
kept: ["write"],
|
||||
shared: ["list"],
|
||||
"srv-shared-b": ["delete"],
|
||||
removed: ["admin"],
|
||||
};
|
||||
const result = extractMcpEntitlement(form({ servers: ["srv-shared-a"] }, permissions), ordered);
|
||||
expect(result?.mcp_tool_permissions).toEqual({ "srv-shared-a": ["read", "write", "list"] });
|
||||
});
|
||||
|
||||
const result = extractMcpEntitlement(form({ servers: ["srv-shared-a"] }, { shared: ["read"] }), ordered as any);
|
||||
expect(result?.mcp_tool_permissions).toEqual({ shared: ["read"] });
|
||||
it("keeps shared-alias restrictions on servers retained through access groups", () => {
|
||||
const catalog: MCPServer[] = [
|
||||
{ ...auditFields, server_id: "kept", alias: "shared", mcp_access_groups: ["retained"] },
|
||||
{ ...auditFields, server_id: "removed", alias: "shared" },
|
||||
];
|
||||
const result = extractMcpEntitlement(form({ accessGroups: ["retained"] }, { shared: ["*"] }), catalog);
|
||||
expect(result?.mcp_tool_permissions).toEqual({ kept: ["*"] });
|
||||
});
|
||||
|
||||
it("drops a shared-name allowlist once no server it names is granted", () => {
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
|||
import MCPServerPermissions from "@/components/permissions/MCPServerPermissions";
|
||||
import { useMCPServers } from "@/app/(dashboard)/hooks/mcpServers/useMCPServers";
|
||||
import { useMCPToolsets } from "@/app/(dashboard)/hooks/mcpServers/useMCPToolsets";
|
||||
import { extractMcpEntitlement } from "@/components/mcp_server_management/mcpEntitlement";
|
||||
import { extractMcpEntitlementUpdate } from "@/components/mcp_server_management/mcpEntitlement";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import ScopedSavingsTab from "@/components/shared/ScopedSavingsTab";
|
||||
import { AutoRouterUsageView } from "@/app/(dashboard)/cost-optimization/_components/AutoRouterBenchmarksTab";
|
||||
|
|
@ -321,7 +321,12 @@ export default function UserInfoView({
|
|||
try {
|
||||
if (!accessToken || !userData) return;
|
||||
|
||||
const mcpEntitlement = extractMcpEntitlement(formValues, allMcpServers, allMcpToolsets);
|
||||
const mcpEntitlement = extractMcpEntitlementUpdate(
|
||||
formValues,
|
||||
userData.object_permission,
|
||||
allMcpServers,
|
||||
allMcpToolsets,
|
||||
);
|
||||
const userFields = Object.fromEntries(
|
||||
Object.entries(formValues).filter(
|
||||
([field]) => field !== "mcp_servers_and_groups" && field !== "mcp_tool_permissions",
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ import {
|
|||
Boxes,
|
||||
ChevronRight,
|
||||
Code2,
|
||||
Contact,
|
||||
Database,
|
||||
ExternalLink,
|
||||
FileText,
|
||||
|
|
@ -350,6 +351,7 @@ const menuGroups: MenuGroup[] = [
|
|||
roles: [...projectReaderRoles],
|
||||
},
|
||||
{ key: "users", page: "users", label: "Internal Users", icon: <User {...ICON} />, roles: all_admin_roles },
|
||||
{ key: "customers", page: "customers", label: "Customers", icon: <Contact {...ICON} />, roles: all_admin_roles },
|
||||
{
|
||||
key: "organizations",
|
||||
page: "organizations",
|
||||
|
|
|
|||
|
|
@ -47,6 +47,20 @@ describe("MCPServerSelector no-mcp-servers option", () => {
|
|||
setupMcpMocks();
|
||||
});
|
||||
|
||||
it("focuses the selector when its form label is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
<>
|
||||
<label htmlFor="mcp-access">MCP Access</label>
|
||||
<MCPServerSelector id="mcp-access" accessToken="tok" onChange={vi.fn()} />
|
||||
</>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByText("MCP Access"));
|
||||
|
||||
expect(screen.getByLabelText("MCP Access")).toHaveFocus();
|
||||
});
|
||||
|
||||
it("hides the No MCP Servers option by default", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
|
|
@ -244,3 +258,42 @@ describe("MCPServerSelector scoping", () => {
|
|||
expect(optionByLabel("Wiki A")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("MCPServerSelector name collisions", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockUseMCPServers.mockReturnValue({
|
||||
data: [
|
||||
{ server_id: "shared", server_name: "Shared" },
|
||||
{ server_id: "srv-x", server_name: "Other" },
|
||||
],
|
||||
isLoading: false,
|
||||
} as any);
|
||||
mockUseMCPAccessGroups.mockReturnValue({ data: ["shared", "alias-a"], isLoading: false } as any);
|
||||
mockUseMCPToolsets.mockReturnValue({ data: [], isLoading: false } as any);
|
||||
});
|
||||
|
||||
it.each(["shared", "alias-a"])(
|
||||
"keeps the stored server %s a server when it matches an access group name",
|
||||
async (server) => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
renderWithProviders(
|
||||
<MCPServerSelector accessToken="tok" onChange={onChange} value={{ servers: [server], accessGroups: [] }} />,
|
||||
);
|
||||
|
||||
await openSelector(user);
|
||||
await user.click(optionByLabel("Other")!);
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({ servers: [server, "srv-x"], accessGroups: [], toolsets: [] });
|
||||
},
|
||||
);
|
||||
|
||||
it("shows a stored server alias by its name", () => {
|
||||
renderWithProviders(
|
||||
<MCPServerSelector accessToken="tok" onChange={vi.fn()} value={{ servers: ["alias-a"], accessGroups: [] }} />,
|
||||
);
|
||||
|
||||
expect(screen.getByLabelText("alias-a")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import type { MCPServer } from "@/components/mcp_tools/types";
|
|||
import { accessGroupNamesOf } from "./effectiveMcpServers";
|
||||
|
||||
interface MCPServerSelectorProps {
|
||||
id?: string;
|
||||
onChange: (selected: { servers: string[]; accessGroups: string[]; toolsets: string[] }) => void;
|
||||
value?: {
|
||||
servers: string[];
|
||||
|
|
@ -25,6 +26,14 @@ interface MCPServerSelectorProps {
|
|||
}
|
||||
|
||||
const TOOLSET_PREFIX = "toolset:";
|
||||
const ACCESS_GROUP_PREFIX = "access-group:";
|
||||
const SERVER_PREFIX = "server:";
|
||||
const SENTINELS: readonly string[] = [ALL_PROXY_MCP_SERVERS_SENTINEL, NO_MCP_SERVERS_SENTINEL];
|
||||
|
||||
const serverValue = (server: string): string => (SENTINELS.includes(server) ? server : `${SERVER_PREFIX}${server}`);
|
||||
|
||||
const valuesWithPrefix = (values: readonly string[], prefix: string): string[] =>
|
||||
values.filter((value) => value.startsWith(prefix)).map((value) => value.slice(prefix.length));
|
||||
|
||||
export const serversWithinAccessGroups = (
|
||||
servers: readonly MCPServer[],
|
||||
|
|
@ -41,6 +50,7 @@ export const serversWithinAccessGroups = (
|
|||
};
|
||||
|
||||
const MCPServerSelector: React.FC<MCPServerSelectorProps> = ({
|
||||
id,
|
||||
onChange,
|
||||
value,
|
||||
className,
|
||||
|
|
@ -58,19 +68,18 @@ const MCPServerSelector: React.FC<MCPServerSelectorProps> = ({
|
|||
|
||||
const loading = serversLoading || groupsLoading || toolsetsLoading;
|
||||
|
||||
const accessGroupSet = new Set(accessGroups);
|
||||
const visibleServers = serversWithinAccessGroups(mcpServers, value?.accessGroups ?? [], value?.servers ?? []);
|
||||
|
||||
// Combine options: access groups + servers + toolsets
|
||||
const options = [
|
||||
...accessGroups.map((group) => ({
|
||||
label: group,
|
||||
value: group,
|
||||
value: `${ACCESS_GROUP_PREFIX}${group}`,
|
||||
description: "Access Group",
|
||||
})),
|
||||
...visibleServers.map((server) => ({
|
||||
label: `${server.server_name || server.server_id} (${server.server_id})`,
|
||||
value: server.server_id,
|
||||
value: serverValue(server.server_id),
|
||||
description: "MCP Server",
|
||||
})),
|
||||
...toolsets.map((toolset) => ({
|
||||
|
|
@ -80,13 +89,24 @@ const MCPServerSelector: React.FC<MCPServerSelectorProps> = ({
|
|||
})),
|
||||
];
|
||||
|
||||
// Flatten value for Select — prefix toolset IDs
|
||||
const selectedValues = [
|
||||
...(value?.servers || []),
|
||||
...(value?.accessGroups || []),
|
||||
...(value?.servers || []).map(serverValue),
|
||||
...(value?.accessGroups || []).map((group) => `${ACCESS_GROUP_PREFIX}${group}`),
|
||||
...(value?.toolsets || []).map((id) => `${TOOLSET_PREFIX}${id}`),
|
||||
];
|
||||
|
||||
const listedValues = new Set(options.map((option) => option.value));
|
||||
const unlistedSelections = [
|
||||
...(value?.servers || [])
|
||||
.filter((server) => !SENTINELS.includes(server))
|
||||
.map((server) => ({ label: server, value: serverValue(server), description: "MCP Server" })),
|
||||
...(value?.accessGroups || []).map((group) => ({
|
||||
label: group,
|
||||
value: `${ACCESS_GROUP_PREFIX}${group}`,
|
||||
description: "Access Group",
|
||||
})),
|
||||
].filter((option) => !listedValues.has(option.value));
|
||||
|
||||
const hasNoMcpServersSelected = allowNoMcpServers && selectedValues.includes(NO_MCP_SERVERS_SENTINEL);
|
||||
const hasAllProxyMcpServersSelected = selectedValues.includes(ALL_PROXY_MCP_SERVERS_SENTINEL);
|
||||
|
||||
|
|
@ -101,13 +121,11 @@ const MCPServerSelector: React.FC<MCPServerSelectorProps> = ({
|
|||
onChange({ servers: [NO_MCP_SERVERS_SENTINEL], accessGroups: [], toolsets: [] });
|
||||
return;
|
||||
}
|
||||
const toolsetsSelected = selected
|
||||
.filter((v) => v.startsWith(TOOLSET_PREFIX))
|
||||
.map((v) => v.slice(TOOLSET_PREFIX.length));
|
||||
const rest = selected.filter((v) => !v.startsWith(TOOLSET_PREFIX));
|
||||
const servers = rest.filter((v) => !accessGroupSet.has(v));
|
||||
const accessGroupsSelected = rest.filter((v) => accessGroupSet.has(v));
|
||||
onChange({ servers, accessGroups: accessGroupsSelected, toolsets: toolsetsSelected });
|
||||
onChange({
|
||||
servers: [...selected.filter((v) => SENTINELS.includes(v)), ...valuesWithPrefix(selected, SERVER_PREFIX)],
|
||||
accessGroups: valuesWithPrefix(selected, ACCESS_GROUP_PREFIX),
|
||||
toolsets: valuesWithPrefix(selected, TOOLSET_PREFIX),
|
||||
});
|
||||
};
|
||||
|
||||
const selectOptions: MultiSelectOption[] = [
|
||||
|
|
@ -117,7 +135,7 @@ const MCPServerSelector: React.FC<MCPServerSelectorProps> = ({
|
|||
...(allowNoMcpServers
|
||||
? [{ label: "No MCP Servers", value: NO_MCP_SERVERS_SENTINEL, description: "Block all" }]
|
||||
: []),
|
||||
...options.map((opt) => ({
|
||||
...[...options, ...unlistedSelections].map((opt) => ({
|
||||
...opt,
|
||||
disabled: hasNoMcpServersSelected || hasAllProxyMcpServersSelected,
|
||||
})),
|
||||
|
|
@ -126,6 +144,7 @@ const MCPServerSelector: React.FC<MCPServerSelectorProps> = ({
|
|||
return (
|
||||
<div>
|
||||
<MultiSelect
|
||||
id={id}
|
||||
options={selectOptions}
|
||||
value={selectedValues}
|
||||
onValueChange={handleChange}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,66 @@ describe("MCPToolPermissions", () => {
|
|||
vi.mocked(networking.fetchMCPAccessGroups).mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it("preserves an unrestricted stored grant until a tool permission is edited", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
const tools = [
|
||||
{ name: "list_records", description: "List records" },
|
||||
{ name: "delete_record", description: "Delete a record" },
|
||||
];
|
||||
vi.mocked(networking.fetchMCPServers).mockResolvedValue([{ server_id: mockServerId, server_name: mockServerName }]);
|
||||
vi.mocked(networking.listMCPTools).mockResolvedValue({ tools, error: false });
|
||||
renderWithProviders(
|
||||
<MCPToolPermissions
|
||||
accessToken={mockAccessToken}
|
||||
selectedServers={[mockServerId]}
|
||||
toolPermissions={{}}
|
||||
onChange={onChange}
|
||||
preserveUnrestrictedServers={[mockServerId]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await screen.findByText("delete_record")).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("radio", { name: "Flat List" }));
|
||||
|
||||
expect(screen.getByRole("checkbox", { name: "delete_record" })).toBeChecked();
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
|
||||
await user.click(screen.getByRole("checkbox", { name: "delete_record" }));
|
||||
|
||||
expect(onChange).toHaveBeenCalledExactlyOnceWith({ [mockServerId]: ["list_records"] }, "edit");
|
||||
});
|
||||
|
||||
it("defaults a newly added edit server to non-delete tools without narrowing stored servers", async () => {
|
||||
const onChange = vi.fn();
|
||||
const newServerId = "new-server";
|
||||
const tools = [
|
||||
{ name: "list_records", description: "List records" },
|
||||
{ name: "delete_record", description: "Delete a record" },
|
||||
];
|
||||
vi.mocked(networking.fetchMCPServers).mockResolvedValue([
|
||||
{ server_id: mockServerId, server_name: mockServerName },
|
||||
{ server_id: newServerId, server_name: "New Server" },
|
||||
]);
|
||||
vi.mocked(networking.listMCPTools).mockResolvedValue({ tools, error: false });
|
||||
const props = {
|
||||
accessToken: mockAccessToken,
|
||||
toolPermissions: {},
|
||||
onChange,
|
||||
preserveUnrestrictedServers: [mockServerId],
|
||||
};
|
||||
const { rerender } = renderWithProviders(<MCPToolPermissions {...props} selectedServers={[mockServerId]} />);
|
||||
|
||||
expect(await screen.findByText("delete_record")).toBeInTheDocument();
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
|
||||
rerender(<MCPToolPermissions {...props} selectedServers={[mockServerId, newServerId]} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onChange).toHaveBeenCalledExactlyOnceWith({ [newServerId]: ["list_records"] }, "default");
|
||||
});
|
||||
});
|
||||
|
||||
it("should update tool permissions when user selects a tool", async () => {
|
||||
/**
|
||||
* Tests that clicking a tool checkbox calls onChange with updated permissions.
|
||||
|
|
|
|||
|
|
@ -1,5 +1,13 @@
|
|||
import { ALL_PROXY_MCP_SERVERS_SENTINEL } from "@/components/mcp_tools/constants";
|
||||
import { ALL_PROXY_MCP_SERVERS_SENTINEL, NO_MCP_SERVERS_SENTINEL } from "@/components/mcp_tools/constants";
|
||||
import { MCPServer, MCPToolset } from "@/components/mcp_tools/types";
|
||||
import type { ObjectPermission } from "@/components/object_permission_types";
|
||||
import {
|
||||
accessGroupNamesOf,
|
||||
mcpAllowedToolsFor,
|
||||
mcpServersForIdentifier,
|
||||
mcpToolPermissionKeysFor,
|
||||
resolveEffectiveMcpServers,
|
||||
} from "./effectiveMcpServers";
|
||||
|
||||
export interface McpEntitlementUpdate {
|
||||
mcp_servers: string[];
|
||||
|
|
@ -18,45 +26,31 @@ const asToolPermissions = (value: unknown): Record<string, string[]> => {
|
|||
);
|
||||
};
|
||||
|
||||
const mcpServerMatchesIdentifier = (server: MCPServer, identifier: string): boolean =>
|
||||
server.server_id === identifier || server.server_name === identifier || server.alias === identifier;
|
||||
const formMcpGrant = (formValues: Record<string, unknown>): McpEntitlementUpdate | null => {
|
||||
const selection = formValues.mcp_servers_and_groups;
|
||||
if (selection === null || typeof selection !== "object") return null;
|
||||
const { servers, accessGroups, toolsets } = selection as Record<string, unknown>;
|
||||
return {
|
||||
mcp_servers: asStringArray(servers),
|
||||
mcp_access_groups: asStringArray(accessGroups),
|
||||
mcp_toolsets: asStringArray(toolsets),
|
||||
mcp_tool_permissions: asToolPermissions(formValues.mcp_tool_permissions),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* The `object_permission` a save sends, derived from what the editor currently shows.
|
||||
*
|
||||
* A tool allowlist is what narrows a grant and an absent one reads as no restriction, so dropping
|
||||
* an entry is the direction that widens. An entry is kept while its own server is still reachable,
|
||||
* directly or through a retained access group or toolset, and dropped once nothing reaches it, which
|
||||
* is what makes removing a grant actually remove it. Reachability is resolved per server rather than
|
||||
* per selection: the gateway treats every allowlist key as an independent server grant, so keeping
|
||||
* every key because some unrelated group survived would leave a deselected server callable.
|
||||
*
|
||||
* The catalog carries `mcp_access_groups` on each server and `tools[].server_id` on each toolset,
|
||||
* which is the same membership the gateway resolves against. A selected toolset missing from the
|
||||
* catalog is unresolvable, so nothing is pruned in that save.
|
||||
*
|
||||
* A tool-permission key may be a server id, a name or an alias: the gateway normalizes all three
|
||||
* before looking up the allowlist, so an entry written by the API or by config can use any of them.
|
||||
* `allServers` is what resolves a key to its servers, plural: names and aliases are not unique, and
|
||||
* the gateway unions such a key into EVERY server answering to it, so the entry is kept while any
|
||||
* one of them is still granted. Resolving to the first match instead would make the outcome depend
|
||||
* on catalog order and could drop a restriction that was also covering a server still granted. A key
|
||||
* that resolves to nothing is kept too, since a server we cannot identify is one we cannot confirm
|
||||
* was deselected; that also covers a catalog that has not loaded or failed to load, where every key
|
||||
* is unresolvable and nothing is pruned.
|
||||
*/
|
||||
export const extractMcpEntitlement = (
|
||||
formValues: Record<string, unknown>,
|
||||
allServers: MCPServer[],
|
||||
allToolsets: MCPToolset[] = [],
|
||||
): McpEntitlementUpdate | null => {
|
||||
const selection = formValues.mcp_servers_and_groups;
|
||||
if (selection === null || typeof selection !== "object") return null;
|
||||
|
||||
const { servers, accessGroups, toolsets } = selection as Record<string, unknown>;
|
||||
const mcpServers = asStringArray(servers);
|
||||
const mcpAccessGroups = asStringArray(accessGroups);
|
||||
const mcpToolsets = asStringArray(toolsets);
|
||||
const formGrant = formMcpGrant(formValues);
|
||||
if (!formGrant) return null;
|
||||
const {
|
||||
mcp_servers: mcpServers,
|
||||
mcp_access_groups: mcpAccessGroups,
|
||||
mcp_toolsets: mcpToolsets,
|
||||
mcp_tool_permissions: toolPermissions,
|
||||
} = formGrant;
|
||||
const grantsEveryServer =
|
||||
mcpServers.includes(ALL_PROXY_MCP_SERVERS_SENTINEL) ||
|
||||
mcpToolsets.some((toolsetId) => !allToolsets.some((toolset) => toolset.toolset_id === toolsetId));
|
||||
|
|
@ -66,26 +60,140 @@ export const extractMcpEntitlement = (
|
|||
.filter((toolset) => mcpToolsets.includes(toolset.toolset_id))
|
||||
.flatMap((toolset) => toolset.tools.map((tool) => tool.server_id)),
|
||||
);
|
||||
const directServerIds = new Set(
|
||||
mcpServers.flatMap((identifier) =>
|
||||
mcpServersForIdentifier(allServers, identifier).map((server) => server.server_id),
|
||||
),
|
||||
);
|
||||
|
||||
const grants = (server: MCPServer): boolean =>
|
||||
mcpServers.some((identifier) => mcpServerMatchesIdentifier(server, identifier)) ||
|
||||
(server.mcp_access_groups ?? []).some((group) => mcpAccessGroups.includes(group)) ||
|
||||
directServerIds.has(server.server_id) ||
|
||||
accessGroupNamesOf(server).some((group) => mcpAccessGroups.includes(group)) ||
|
||||
toolsetServerIds.has(server.server_id);
|
||||
|
||||
const grantsServerNamedBy = (permissionKey: string): boolean => {
|
||||
const named = allServers.filter((candidate) => mcpServerMatchesIdentifier(candidate, permissionKey));
|
||||
const named = mcpServersForIdentifier(allServers, permissionKey);
|
||||
if (named.length === 0) return true;
|
||||
return named.some(grants);
|
||||
};
|
||||
|
||||
const splitKeys = new Set(
|
||||
Object.keys(toolPermissions).filter((key) => {
|
||||
const named = mcpServersForIdentifier(allServers, key);
|
||||
return !grantsEveryServer && named.length > 1 && named.some((server) => !grants(server));
|
||||
}),
|
||||
);
|
||||
const retainedServerIds = new Set(
|
||||
Array.from(splitKeys).flatMap((key) =>
|
||||
mcpServersForIdentifier(allServers, key)
|
||||
.filter(grants)
|
||||
.map((server) => server.server_id),
|
||||
),
|
||||
);
|
||||
const keepPermissionKey = (key: string): boolean => {
|
||||
if (splitKeys.has(key)) return false;
|
||||
const named = mcpServersForIdentifier(allServers, key);
|
||||
if (named.length === 1 && retainedServerIds.has(named[0].server_id)) return false;
|
||||
return grantsEveryServer || grantsServerNamedBy(key);
|
||||
};
|
||||
const retainedPermissions = Object.fromEntries(
|
||||
Object.entries(toolPermissions).filter(([key]) => keepPermissionKey(key)),
|
||||
);
|
||||
const canonicalPermissions = Object.fromEntries(
|
||||
allServers
|
||||
.filter((server) => retainedServerIds.has(server.server_id))
|
||||
.map((server) => [server.server_id, [...(mcpAllowedToolsFor(server, toolPermissions, allServers) ?? [])]]),
|
||||
);
|
||||
|
||||
return {
|
||||
mcp_servers: mcpServers,
|
||||
mcp_access_groups: mcpAccessGroups,
|
||||
mcp_toolsets: mcpToolsets,
|
||||
mcp_tool_permissions: Object.fromEntries(
|
||||
Object.entries(asToolPermissions(formValues.mcp_tool_permissions)).filter(
|
||||
([permissionKey]) => grantsEveryServer || grantsServerNamedBy(permissionKey),
|
||||
),
|
||||
),
|
||||
mcp_tool_permissions: { ...retainedPermissions, ...canonicalPermissions },
|
||||
};
|
||||
};
|
||||
|
||||
const normalizedStrings = (values: string[]): string[] => Array.from(new Set(values)).toSorted();
|
||||
|
||||
const grantIdentity = (grant: McpEntitlementUpdate): string => {
|
||||
const normalized = {
|
||||
mcp_servers: normalizedStrings(grant.mcp_servers),
|
||||
mcp_access_groups: normalizedStrings(grant.mcp_access_groups),
|
||||
mcp_toolsets: normalizedStrings(grant.mcp_toolsets),
|
||||
mcp_tool_permissions: Object.fromEntries(
|
||||
Object.keys(grant.mcp_tool_permissions)
|
||||
.toSorted()
|
||||
.map((server) => [server, normalizedStrings(grant.mcp_tool_permissions[server])]),
|
||||
),
|
||||
};
|
||||
return JSON.stringify(normalized);
|
||||
};
|
||||
|
||||
const independentToolPermissions = (
|
||||
storedGrant: McpEntitlementUpdate,
|
||||
toolPermissions: Record<string, string[]>,
|
||||
allServers: MCPServer[],
|
||||
allToolsets: MCPToolset[],
|
||||
): Record<string, string[]> => {
|
||||
if (storedGrant.mcp_servers.includes(NO_MCP_SERVERS_SENTINEL)) return {};
|
||||
const storedSelection = {
|
||||
allServers,
|
||||
selectedServers: storedGrant.mcp_servers,
|
||||
selectedAccessGroups: storedGrant.mcp_access_groups,
|
||||
selectedToolsets: storedGrant.mcp_toolsets,
|
||||
toolsets: allToolsets,
|
||||
toolPermissions: {},
|
||||
};
|
||||
const selectedServers = resolveEffectiveMcpServers(storedSelection);
|
||||
const selectedServerIds = new Set(selectedServers.map(({ server }) => server.server_id));
|
||||
const keyOnlyServers = Object.keys(storedGrant.mcp_tool_permissions)
|
||||
.flatMap((key) => mcpServersForIdentifier(allServers, key))
|
||||
.filter((server) => !selectedServerIds.has(server.server_id));
|
||||
const keyOnlyServerIds = new Set(keyOnlyServers.map((server) => server.server_id));
|
||||
const namesOnlyKeyOnlyServers = (key: string): boolean => {
|
||||
const named = mcpServersForIdentifier(allServers, key);
|
||||
return named.length > 0 && named.every((server) => keyOnlyServerIds.has(server.server_id));
|
||||
};
|
||||
const independentPermissions = Object.fromEntries(
|
||||
Object.entries(toolPermissions).filter(([key]) => namesOnlyKeyOnlyServers(key)),
|
||||
);
|
||||
const canonicalPermissions = Object.fromEntries(
|
||||
keyOnlyServers
|
||||
.filter((server) => mcpToolPermissionKeysFor(server, independentPermissions, allServers).length === 0)
|
||||
.flatMap((server) => {
|
||||
const allowed = mcpAllowedToolsFor(server, toolPermissions, allServers);
|
||||
return allowed ? [[server.server_id, [...allowed]]] : [];
|
||||
}),
|
||||
);
|
||||
return { ...independentPermissions, ...canonicalPermissions };
|
||||
};
|
||||
|
||||
export const extractMcpEntitlementUpdate = (
|
||||
formValues: Record<string, unknown>,
|
||||
existingPermission: ObjectPermission | null | undefined,
|
||||
allServers: MCPServer[],
|
||||
allToolsets: MCPToolset[] = [],
|
||||
): McpEntitlementUpdate | null => {
|
||||
const formGrant = formMcpGrant(formValues);
|
||||
if (!formGrant) return null;
|
||||
const storedGrant = {
|
||||
mcp_servers: existingPermission?.mcp_servers ?? [],
|
||||
mcp_access_groups: existingPermission?.mcp_access_groups ?? [],
|
||||
mcp_toolsets: existingPermission?.mcp_toolsets ?? [],
|
||||
mcp_tool_permissions: existingPermission?.mcp_tool_permissions ?? {},
|
||||
};
|
||||
const storedIdentity = grantIdentity(storedGrant);
|
||||
if (grantIdentity(formGrant) === storedIdentity) return null;
|
||||
const extracted = extractMcpEntitlement(formValues, allServers, allToolsets);
|
||||
if (!extracted) return null;
|
||||
const grant = {
|
||||
...extracted,
|
||||
mcp_tool_permissions: formGrant.mcp_servers.includes(NO_MCP_SERVERS_SENTINEL)
|
||||
? {}
|
||||
: {
|
||||
...extracted.mcp_tool_permissions,
|
||||
...independentToolPermissions(storedGrant, formGrant.mcp_tool_permissions, allServers, allToolsets),
|
||||
},
|
||||
};
|
||||
return grantIdentity(grant) === storedIdentity ? null : grant;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ export const pageDescriptions: Record<string, string> = {
|
|||
lens: "Review agent activity and investigate patterns with supporting evidence",
|
||||
"guardrails-monitor": "Monitor guardrail performance and view logs",
|
||||
users: "Manage internal user accounts and permissions",
|
||||
customers: "Manage end-user spend, budgets, rate limits, model and MCP access",
|
||||
teams: "Create and manage teams for access control",
|
||||
organizations: "Manage organizations and their members",
|
||||
projects: "Manage projects within teams",
|
||||
|
|
|
|||
|
|
@ -157,6 +157,39 @@ describe("MCPServerPermissions", () => {
|
|||
expect(screen.queryByText(/tool/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the existing key display for empty tool allowlists", async () => {
|
||||
vi.mocked(networking.fetchMCPServers).mockResolvedValue([
|
||||
{ server_id: mockServerId1, server_name: mockServerName1, alias: mockServerName1 },
|
||||
]);
|
||||
render(
|
||||
<MCPServerPermissions
|
||||
mcpServers={[mockServerId1]}
|
||||
mcpToolPermissions={{ [mockServerId1]: [] }}
|
||||
accessToken={mockAccessToken}
|
||||
/>,
|
||||
);
|
||||
expect(await screen.findByText(/DW_MCP/)).toBeInTheDocument();
|
||||
expect(screen.queryByText("No tools allowed")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("labels an empty tool allowlist as allowing no tools", async () => {
|
||||
vi.mocked(networking.fetchMCPServers).mockResolvedValue([
|
||||
{ server_id: mockServerId1, server_name: mockServerName1, alias: mockServerName1 },
|
||||
]);
|
||||
|
||||
render(
|
||||
<MCPServerPermissions
|
||||
mcpServers={[]}
|
||||
mcpToolPermissions={{ [mockServerId1]: [] }}
|
||||
entityType="customer"
|
||||
accessToken={mockAccessToken}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await screen.findByText(/DW_MCP/)).toBeInTheDocument();
|
||||
expect(screen.getByText("No tools allowed")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display access groups correctly", async () => {
|
||||
/**
|
||||
* Tests that access groups are displayed with the correct styling
|
||||
|
|
@ -464,6 +497,46 @@ describe("MCPServerPermissions", () => {
|
|||
/>,
|
||||
);
|
||||
await waitFor(() => expect(screen.getByText("Blocked")).toHaveAttribute("data-variant", "destructive"));
|
||||
expect(
|
||||
screen.getByText("No MCP servers. This key is blocked from all MCP servers, including its team's servers"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each(["server", "tool permission"])("shows no customer access for an unsupported %s sentinel", (source) => {
|
||||
render(
|
||||
<MCPServerPermissions
|
||||
entityType="customer"
|
||||
mcpServers={source === "server" ? [ALL_PROXY_MCP_SERVERS_SENTINEL] : []}
|
||||
mcpToolPermissions={source === "tool permission" ? { [ALL_PROXY_MCP_SERVERS_SENTINEL]: ["read"] } : {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("No MCP access")).toBeInTheDocument();
|
||||
expect(screen.getByText("0")).toBeInTheDocument();
|
||||
expect(screen.queryByText("All")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each(["direct", "tool permission"])(
|
||||
"shows the customer's concrete %s grant beside an unsupported sentinel",
|
||||
(source) => {
|
||||
render(
|
||||
<MCPServerPermissions
|
||||
entityType="customer"
|
||||
mcpServers={[ALL_PROXY_MCP_SERVERS_SENTINEL, ...(source === "direct" ? [mockServerId1] : [])]}
|
||||
mcpToolPermissions={source === "tool permission" ? { [mockServerId1]: ["read", "write"] } : {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText(mockServerId1)).toBeInTheDocument();
|
||||
expect(screen.getByText("1")).toBeInTheDocument();
|
||||
expect(screen.queryByText("All")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("No MCP access")).not.toBeInTheDocument();
|
||||
},
|
||||
);
|
||||
|
||||
it("uses customer-specific blocked copy without implying a team relationship", () => {
|
||||
render(<MCPServerPermissions mcpServers={[NO_MCP_SERVERS_SENTINEL]} entityType="customer" />);
|
||||
expect(screen.getByText("No MCP servers. This customer is blocked from all MCP servers")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("lists servers inherited from access groups, counts them, and names the group on hover", async () => {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { InheritedGrant, inheritedGrantTooltip } from "./inheritedGrants";
|
|||
|
||||
interface MCPServerPermissionsProps {
|
||||
mcpServers: string[];
|
||||
entityType?: "key" | "customer";
|
||||
mcpAccessGroups?: string[];
|
||||
mcpToolPermissions?: Record<string, string[]>;
|
||||
mcpToolsets?: string[];
|
||||
|
|
@ -19,6 +20,7 @@ interface MCPServerPermissionsProps {
|
|||
|
||||
export function MCPServerPermissions({
|
||||
mcpServers,
|
||||
entityType = "key",
|
||||
mcpAccessGroups = [],
|
||||
mcpToolPermissions = {},
|
||||
mcpToolsets = [],
|
||||
|
|
@ -54,10 +56,12 @@ export function MCPServerPermissions({
|
|||
});
|
||||
};
|
||||
|
||||
const directServerIds = mcpServers.filter(
|
||||
const serverIdentifiers =
|
||||
entityType === "customer" ? [...new Set([...mcpServers, ...Object.keys(mcpToolPermissions)])] : mcpServers;
|
||||
const directServerIds = serverIdentifiers.filter(
|
||||
(server) => server !== NO_MCP_SERVERS_SENTINEL && server !== ALL_PROXY_MCP_SERVERS_SENTINEL,
|
||||
);
|
||||
const inheritedOnlyServers = inheritedMcpServers.filter((grant) => !mcpServers.includes(grant.id));
|
||||
const inheritedOnlyServers = inheritedMcpServers.filter((grant) => !directServerIds.includes(grant.id));
|
||||
const serverIdCount = directServerIds.length + inheritedOnlyServers.length;
|
||||
|
||||
// Fetch MCP server details when component mounts
|
||||
|
|
@ -118,7 +122,8 @@ export function MCPServerPermissions({
|
|||
};
|
||||
|
||||
const blocksAllMcpServers = mcpServers.includes(NO_MCP_SERVERS_SENTINEL);
|
||||
const grantsAllProxyMcpServers = mcpServers.includes(ALL_PROXY_MCP_SERVERS_SENTINEL);
|
||||
const hasAllProxySentinel = serverIdentifiers.includes(ALL_PROXY_MCP_SERVERS_SENTINEL);
|
||||
const grantsAllProxyMcpServers = entityType === "key" && hasAllProxySentinel;
|
||||
|
||||
const mergedItems = [
|
||||
...directServerIds.map((server) => ({ type: "server", value: server, tooltip: `Full ID: ${server}` })),
|
||||
|
|
@ -145,7 +150,9 @@ export function MCPServerPermissions({
|
|||
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-destructive/10 border border-destructive/20">
|
||||
<ServerIcon className="h-4 w-4 text-destructive" />
|
||||
<p className="text-destructive text-sm">
|
||||
No MCP servers — this key is blocked from all MCP servers, including its team's servers
|
||||
{entityType === "key"
|
||||
? "No MCP servers. This key is blocked from all MCP servers, including its team's servers"
|
||||
: "No MCP servers. This customer is blocked from all MCP servers"}
|
||||
</p>
|
||||
</div>
|
||||
) : grantsAllProxyMcpServers ? (
|
||||
|
|
@ -158,6 +165,7 @@ export function MCPServerPermissions({
|
|||
{mergedItems.map((item, index) => {
|
||||
const toolsForServer = item.type === "server" ? getToolPermissionsFor(item.value) : undefined;
|
||||
const hasToolRestrictions = toolsForServer && toolsForServer.length > 0;
|
||||
const allowsNoTools = entityType === "customer" && toolsForServer?.length === 0;
|
||||
const isExpanded = expandedServers.has(item.value);
|
||||
|
||||
return (
|
||||
|
|
@ -190,6 +198,7 @@ export function MCPServerPermissions({
|
|||
)}
|
||||
</div>
|
||||
|
||||
{allowsNoTools && <span className="shrink-0 text-xs text-muted-foreground">No tools allowed</span>}
|
||||
{hasToolRestrictions && (
|
||||
<div className="flex items-center gap-1 shrink-0 whitespace-nowrap">
|
||||
<span className="text-xs font-medium text-muted-foreground">{toolsForServer.length}</span>
|
||||
|
|
@ -285,7 +294,11 @@ export function MCPServerPermissions({
|
|||
) : (
|
||||
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-muted border border-border">
|
||||
<ServerIcon className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="text-muted-foreground text-sm">No MCP servers, access groups, or toolsets configured</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{entityType === "customer" && hasAllProxySentinel
|
||||
? "No MCP access"
|
||||
: "No MCP servers, access groups, or toolsets configured"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -62,6 +62,17 @@ describe("ModelsCell", () => {
|
|||
expect(await screen.findByText("d")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([[], ["all-proxy-models"], ["*"]])("uses the customer model scope label for %j", (models) => {
|
||||
render(<ModelsCell models={models} unrestrictedLabel="All models the key can use" />);
|
||||
expect(screen.getByText("All models the key can use")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the literal wildcard label on existing pages", () => {
|
||||
render(<ModelsCell models={["*"]} />);
|
||||
expect(screen.getByText("*")).toBeInTheDocument();
|
||||
expect(screen.queryByText("All Proxy Models")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("labels the all-proxy-models wildcard", () => {
|
||||
render(<ModelsCell models={["all-proxy-models"]} />);
|
||||
expect(screen.getByText("All Proxy Models")).toBeInTheDocument();
|
||||
|
|
|
|||
|
|
@ -9,21 +9,22 @@ import { CellTooltip } from "./cell_tooltip";
|
|||
interface ModelsCellProps {
|
||||
models: string[] | null | undefined;
|
||||
maxVisible?: number;
|
||||
unrestrictedLabel?: string;
|
||||
allowedRoutes?: string[] | null;
|
||||
keyType?: string | null;
|
||||
}
|
||||
|
||||
const WILDCARD_MODEL = "all-proxy-models";
|
||||
|
||||
const formatModel = (model: string): string => {
|
||||
if (model === WILDCARD_MODEL) {
|
||||
return "All Proxy Models";
|
||||
const formatModel = (model: string, unrestrictedLabel?: string): string => {
|
||||
if (model === WILDCARD_MODEL || (model === "*" && unrestrictedLabel !== undefined)) {
|
||||
return unrestrictedLabel ?? "All Proxy Models";
|
||||
}
|
||||
const name = getModelDisplayName(model);
|
||||
return name.length > 30 ? `${name.slice(0, 30)}...` : name;
|
||||
};
|
||||
|
||||
export function ModelsCell({ models, maxVisible = 3, allowedRoutes, keyType }: ModelsCellProps) {
|
||||
export function ModelsCell({ models, maxVisible = 3, unrestrictedLabel, allowedRoutes, keyType }: ModelsCellProps) {
|
||||
if (!Array.isArray(models) || models.length === 0) {
|
||||
const scope = deriveKeyModelScope(allowedRoutes, keyType);
|
||||
if (!scope.hasModelAccess) {
|
||||
|
|
@ -38,7 +39,7 @@ export function ModelsCell({ models, maxVisible = 3, allowedRoutes, keyType }: M
|
|||
/>
|
||||
);
|
||||
}
|
||||
return <Badge variant="secondary">All Proxy Models</Badge>;
|
||||
return <Badge variant="secondary">{unrestrictedLabel ?? "All Proxy Models"}</Badge>;
|
||||
}
|
||||
|
||||
const visible = models.slice(0, maxVisible);
|
||||
|
|
@ -48,7 +49,7 @@ export function ModelsCell({ models, maxVisible = 3, allowedRoutes, keyType }: M
|
|||
<div className="flex flex-wrap items-center gap-1">
|
||||
{visible.map((model, index) => (
|
||||
<Badge key={index} variant={model === WILDCARD_MODEL ? "secondary" : "outline"}>
|
||||
{formatModel(model)}
|
||||
{formatModel(model, unrestrictedLabel)}
|
||||
</Badge>
|
||||
))}
|
||||
{overflow.length > 0 && (
|
||||
|
|
@ -56,7 +57,7 @@ export function ModelsCell({ models, maxVisible = 3, allowedRoutes, keyType }: M
|
|||
content={
|
||||
<div className="flex max-w-[280px] flex-col gap-0.5">
|
||||
{overflow.map((model, index) => (
|
||||
<span key={index}>{formatModel(model)}</span>
|
||||
<span key={index}>{formatModel(model, unrestrictedLabel)}</span>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { useMCPServers } from "@/app/(dashboard)/hooks/mcpServers/useMCPServers";
|
||||
import type { KeyResponse } from "../key_team_helpers/key_list";
|
||||
import type { MCPServer } from "../mcp_tools/types";
|
||||
import { NO_MCP_SERVERS_SENTINEL } from "../mcp_tools/constants";
|
||||
import { toKeyEditFormValues, toSubmittedValues } from "./keyEditFormValues";
|
||||
|
||||
vi.mock("@/lib/toast", () => ({
|
||||
toast: { error: vi.fn(), success: vi.fn() },
|
||||
|
|
@ -228,7 +233,7 @@ const baseKeyData = {
|
|||
next_rotation_at: null as any,
|
||||
};
|
||||
|
||||
const renderView = (premiumUser: boolean) => {
|
||||
const renderView = (premiumUser: boolean, keyData: KeyResponse = baseKeyData as KeyResponse) => {
|
||||
// Configure the mock for this test
|
||||
mockUseAuthorized.mockReturnValue({
|
||||
accessToken: "access_abc",
|
||||
|
|
@ -242,18 +247,13 @@ const renderView = (premiumUser: boolean) => {
|
|||
});
|
||||
|
||||
return render(
|
||||
<KeyInfoView
|
||||
keyId="tok_123"
|
||||
onClose={() => {}}
|
||||
keyData={baseKeyData as any}
|
||||
onKeyDataUpdate={() => {}}
|
||||
teams={[]}
|
||||
/>,
|
||||
<KeyInfoView keyId="tok_123" onClose={() => {}} keyData={keyData} onKeyDataUpdate={() => {}} teams={[]} />,
|
||||
);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(useMCPServers).mockReturnValue({ data: [] } as ReturnType<typeof useMCPServers>);
|
||||
(globalThis as any).__TEST_FORM_VALUES = undefined;
|
||||
});
|
||||
|
||||
|
|
@ -375,6 +375,85 @@ describe("KeyInfoView handleKeyUpdate mcp_toolsets", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("KeyInfoView handleKeyUpdate shared MCP aliases", () => {
|
||||
it.each([
|
||||
{
|
||||
edit: "only renaming the key",
|
||||
storedServers: ["server-a"],
|
||||
storedToolPermissions: { shared: ["read"] },
|
||||
selectedServers: ["server-a"],
|
||||
expectedToolPermissions: { shared: ["read"] },
|
||||
},
|
||||
{
|
||||
edit: "removing an explicitly selected server",
|
||||
storedServers: ["server-a", "server-b"],
|
||||
storedToolPermissions: { shared: ["read"] },
|
||||
selectedServers: ["server-a"],
|
||||
expectedToolPermissions: { "server-a": ["read"] },
|
||||
},
|
||||
{
|
||||
edit: "choosing No MCP Servers",
|
||||
storedServers: ["server-a"],
|
||||
storedToolPermissions: { "server-b": ["read"] },
|
||||
selectedServers: [NO_MCP_SERVERS_SENTINEL],
|
||||
expectedToolPermissions: {},
|
||||
},
|
||||
])(
|
||||
"preserves unrelated permissions when $edit",
|
||||
async ({ storedServers, storedToolPermissions, selectedServers, expectedToolPermissions }) => {
|
||||
const servers: MCPServer[] = ["server-a", "server-b"].map((server_id) => ({
|
||||
server_id,
|
||||
alias: "shared",
|
||||
created_at: "",
|
||||
created_by: "",
|
||||
updated_at: "",
|
||||
updated_by: "",
|
||||
}));
|
||||
vi.mocked(useMCPServers).mockReturnValue({ data: servers } as ReturnType<typeof useMCPServers>);
|
||||
const permission = {
|
||||
mcp_servers: storedServers,
|
||||
mcp_access_groups: [],
|
||||
mcp_toolsets: [],
|
||||
mcp_tool_permissions: storedToolPermissions,
|
||||
vector_stores: ["vector-store"],
|
||||
agents: ["agent"],
|
||||
agent_access_groups: ["agent-group"],
|
||||
skills: ["skill"],
|
||||
search_tools: ["search-tool"],
|
||||
};
|
||||
const keyData = { ...baseKeyData, object_permission: permission } as KeyResponse;
|
||||
renderView(true, keyData);
|
||||
fireEvent.click(screen.getByText("Settings"));
|
||||
fireEvent.click(screen.getByText("Edit Settings"));
|
||||
(globalThis as { __TEST_FORM_VALUES?: Record<string, unknown> }).__TEST_FORM_VALUES = toSubmittedValues(
|
||||
{
|
||||
...toKeyEditFormValues(keyData),
|
||||
key_alias: "Renamed key",
|
||||
mcp_servers_and_groups: { servers: selectedServers, accessGroups: [], toolsets: [] },
|
||||
},
|
||||
{ canViewPolicies: true, canViewPrompts: true },
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("Mock Submit"));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(keyUpdateCallMock).toHaveBeenCalledExactlyOnceWith(
|
||||
"access_abc",
|
||||
expect.objectContaining({
|
||||
key: keyData.token,
|
||||
key_alias: "Renamed key",
|
||||
object_permission: {
|
||||
...permission,
|
||||
mcp_servers: selectedServers,
|
||||
mcp_tool_permissions: expectedToolPermissions,
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("KeyInfoView handleKeyUpdate cache sync", () => {
|
||||
it("should invalidate every cached key query so the list and detail views re-read the saved key", async () => {
|
||||
keyUpdateCallMock.mockResolvedValueOnce({
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ import { useQueryClient } from "@tanstack/react-query";
|
|||
import { useMCPServers } from "@/app/(dashboard)/hooks/mcpServers/useMCPServers";
|
||||
import { useMCPToolsets } from "@/app/(dashboard)/hooks/mcpServers/useMCPToolsets";
|
||||
import { useTeamMemberBudgets } from "@/app/(dashboard)/hooks/teams/useTeamMemberBudgets";
|
||||
import { extractMcpEntitlement } from "../mcp_server_management/mcpEntitlement";
|
||||
import { extractMcpEntitlementUpdate } from "../mcp_server_management/mcpEntitlement";
|
||||
import ObjectPermissionsView from "../object_permissions_view";
|
||||
import { RegenerateKeyModal } from "../organisms/RegenerateKeyModal";
|
||||
import { parseErrorMessage } from "../shared/errorUtils";
|
||||
|
|
@ -269,7 +269,12 @@ export default function KeyInfoView({
|
|||
delete formValues.vector_stores;
|
||||
}
|
||||
|
||||
const mcpEntitlement = extractMcpEntitlement(formValues, allMcpServers ?? [], allMcpToolsets ?? []);
|
||||
const mcpEntitlement = extractMcpEntitlementUpdate(
|
||||
formValues,
|
||||
currentKeyData.object_permission,
|
||||
allMcpServers ?? [],
|
||||
allMcpToolsets ?? [],
|
||||
);
|
||||
if (mcpEntitlement) {
|
||||
// Without a catalog the grants an allowlist key still has are unresolvable, so nothing is
|
||||
// pruned and a revocation would save as a no-op while reporting success. Refuse instead.
|
||||
|
|
|
|||
187
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
187
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -4174,6 +4174,47 @@ export interface paths {
|
|||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/customer/bulk_update": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Bulk Customer Update
|
||||
* @description Update up to 500 customers with the same fields as `/customer/update`. Only proxy admins can call it.
|
||||
*
|
||||
* Each item runs in its own locked transaction, so a failed item rolls back only its own writes and
|
||||
* the rest still apply. Results come back in request order with `user_id`, `success`, and either
|
||||
* `updated_customer` or `error`.
|
||||
*
|
||||
* Omitted or null permission fields keep their stored values, supplied lists replace that field,
|
||||
* and empty lists or objects clear it.
|
||||
*
|
||||
* `max_budget` only updates a budget used by that customer alone. Shared and default budgets must be
|
||||
* edited through the budget endpoints. Sending both `budget_id` and `max_budget` in any item, even
|
||||
* as null, fails the whole request with HTTP 422.
|
||||
*
|
||||
* Example request body:
|
||||
* ```json
|
||||
* {
|
||||
* "customers": [
|
||||
* {"user_id": "customer-1", "object_permission": {"mcp_toolsets": ["toolset-1"]}},
|
||||
* {"user_id": "customer-2", "models": ["model-1"]}
|
||||
* ]
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
post: operations["bulk_customer_update_customer_bulk_update_post"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/customer/daily/activity": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
@ -4503,12 +4544,13 @@ export interface paths {
|
|||
* Supported fields:
|
||||
* * mcp_servers: List[str] - List of allowed MCP server IDs
|
||||
* * mcp_access_groups: List[str] - List of MCP access group names
|
||||
* * mcp_toolsets: List[str] - List of allowed MCP toolset IDs
|
||||
* * mcp_tool_permissions: Dict[str, List[str]] - Map of server ID to allowed tool names
|
||||
* * vector_stores: List[str] - List of allowed vector store IDs
|
||||
* * agents: List[str] - List of allowed agent IDs
|
||||
* * agent_access_groups: List[str] - List of agent access group names
|
||||
* Example: {"mcp_servers": ["server_1"], "vector_stores": ["vector_store_1"]}
|
||||
* IF null or {} then no object-level restrictions apply.
|
||||
* Omitted permission fields retain their existing values. Supplying null or {} leaves permissions unchanged.
|
||||
*
|
||||
* Example curl:
|
||||
* ```
|
||||
|
|
@ -4852,6 +4894,47 @@ export interface paths {
|
|||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/end_user/bulk_update": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Bulk Customer Update
|
||||
* @description Update up to 500 customers with the same fields as `/customer/update`. Only proxy admins can call it.
|
||||
*
|
||||
* Each item runs in its own locked transaction, so a failed item rolls back only its own writes and
|
||||
* the rest still apply. Results come back in request order with `user_id`, `success`, and either
|
||||
* `updated_customer` or `error`.
|
||||
*
|
||||
* Omitted or null permission fields keep their stored values, supplied lists replace that field,
|
||||
* and empty lists or objects clear it.
|
||||
*
|
||||
* `max_budget` only updates a budget used by that customer alone. Shared and default budgets must be
|
||||
* edited through the budget endpoints. Sending both `budget_id` and `max_budget` in any item, even
|
||||
* as null, fails the whole request with HTTP 422.
|
||||
*
|
||||
* Example request body:
|
||||
* ```json
|
||||
* {
|
||||
* "customers": [
|
||||
* {"user_id": "customer-1", "object_permission": {"mcp_toolsets": ["toolset-1"]}},
|
||||
* {"user_id": "customer-2", "models": ["model-1"]}
|
||||
* ]
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
post: operations["bulk_customer_update_end_user_bulk_update_post"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/end_user/daily/activity": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
@ -5181,12 +5264,13 @@ export interface paths {
|
|||
* Supported fields:
|
||||
* * mcp_servers: List[str] - List of allowed MCP server IDs
|
||||
* * mcp_access_groups: List[str] - List of MCP access group names
|
||||
* * mcp_toolsets: List[str] - List of allowed MCP toolset IDs
|
||||
* * mcp_tool_permissions: Dict[str, List[str]] - Map of server ID to allowed tool names
|
||||
* * vector_stores: List[str] - List of allowed vector store IDs
|
||||
* * agents: List[str] - List of allowed agent IDs
|
||||
* * agent_access_groups: List[str] - List of agent access group names
|
||||
* Example: {"mcp_servers": ["server_1"], "vector_stores": ["vector_store_1"]}
|
||||
* IF null or {} then no object-level restrictions apply.
|
||||
* Omitted permission fields retain their existing values. Supplying null or {} leaves permissions unchanged.
|
||||
*
|
||||
* Example curl:
|
||||
* ```
|
||||
|
|
@ -28019,6 +28103,25 @@ export interface components {
|
|||
/** Data */
|
||||
data: components["schemas"]["TeamMemberDeleteResult"][];
|
||||
};
|
||||
/** BulkUpdateCustomerRequest */
|
||||
BulkUpdateCustomerRequest: {
|
||||
/**
|
||||
* Customers
|
||||
* @description Customer updates processed independently in request order
|
||||
*/
|
||||
customers: components["schemas"]["UpdateCustomerRequest"][];
|
||||
};
|
||||
/** BulkUpdateCustomerResponse */
|
||||
BulkUpdateCustomerResponse: {
|
||||
/** Failed Updates */
|
||||
failed_updates: number;
|
||||
/** Results */
|
||||
results: components["schemas"]["CustomerUpdateResult"][];
|
||||
/** Successful Updates */
|
||||
successful_updates: number;
|
||||
/** Total Requested */
|
||||
total_requested: number;
|
||||
};
|
||||
/**
|
||||
* BulkUpdateKeyRequest
|
||||
* @description Request for bulk key updates
|
||||
|
|
@ -31247,6 +31350,16 @@ export interface components {
|
|||
/** User Id */
|
||||
user_id: string;
|
||||
};
|
||||
/** CustomerUpdateResult */
|
||||
CustomerUpdateResult: {
|
||||
/** Error */
|
||||
error?: string | null;
|
||||
/** Success */
|
||||
success: boolean;
|
||||
updated_customer?: components["schemas"]["CustomerResponse"] | null;
|
||||
/** User Id */
|
||||
user_id: string;
|
||||
};
|
||||
/**
|
||||
* CyberArkConfig
|
||||
* @description Configuration for CyberArk Conjur secret manager integration.
|
||||
|
|
@ -57003,6 +57116,39 @@ export interface operations {
|
|||
};
|
||||
};
|
||||
};
|
||||
bulk_customer_update_customer_bulk_update_post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["BulkUpdateCustomerRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["BulkUpdateCustomerResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
get_customer_daily_activity_customer_daily_activity_get: {
|
||||
parameters: {
|
||||
query?: {
|
||||
|
|
@ -58024,7 +58170,40 @@ export interface operations {
|
|||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": unknown;
|
||||
"application/json": components["schemas"]["BlockUsersResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
bulk_customer_update_end_user_bulk_update_post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["BulkUpdateCustomerRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["BulkUpdateCustomerResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
|
|
@ -58409,7 +58588,7 @@ export interface operations {
|
|||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": unknown;
|
||||
"application/json": components["schemas"]["UnblockUsersResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue