This commit is contained in:
lewisco 2026-10-19 22:13:02 +00:00 • committed by GitHub
commit ebce99bd86
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
70 changed files with 8281 additions and 458 deletions

View file

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

View file

@ -0,0 +1 @@
CREATE INDEX CONCURRENTLY IF NOT EXISTS "LiteLLM_EndUserTable_budget_id_idx" ON "LiteLLM_EndUserTable" ("budget_id");

View file

@ -0,0 +1 @@
CREATE INDEX CONCURRENTLY IF NOT EXISTS "LiteLLM_VerificationToken_budget_id_idx" ON "LiteLLM_VerificationToken" ("budget_id");

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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"]})

View file

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

View file

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

View file

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

View file

@ -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"]}},
)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,7 @@
"use client";
import CustomersPanel from "./_components/CustomersPanel";
export default function CustomersPage() {
return <CustomersPanel />;
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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&apos;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>

View file

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

View file

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

View file

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

View file

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

View file

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