mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-12 03:48:56 +00:00
Merge 869d4d8c4c into 2a01fe6e5c
This commit is contained in:
commit
888d76cfc4
14 changed files with 771 additions and 63 deletions
|
|
@ -112,9 +112,12 @@ from litellm.proxy.management_endpoints.model_management_endpoints import ( # n
|
|||
)
|
||||
from litellm.proxy.management_endpoints.router_weights import validate_router_settings_weights
|
||||
from litellm.proxy.management_endpoints.team_admin_field_permissions import (
|
||||
TEAM_ADMIN_MEMBER_KEY_TYPE,
|
||||
team_admin_key_edit_verdict,
|
||||
team_admin_key_request_or_raise,
|
||||
team_admin_may_edit_member_key_budgets,
|
||||
team_admin_member_key_creation_or_raise,
|
||||
team_admin_member_key_creation_verdict,
|
||||
)
|
||||
from litellm.proxy.management_helpers.access_group_key_sync import (
|
||||
sync_key_access_group_membership,
|
||||
|
|
@ -1215,6 +1218,37 @@ def _enforce_upperbound_key_params(
|
|||
)
|
||||
|
||||
|
||||
def _team_admin_requests_member_key(
|
||||
data: GenerateKeyRequest,
|
||||
team_table: LiteLLM_TeamTableCachedObj | None,
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
) -> bool:
|
||||
"""Whether the caller is a team admin asking for a team key owned by another member of that team."""
|
||||
return not (
|
||||
user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN.value
|
||||
or team_table is None
|
||||
or data.user_id is None
|
||||
or data.user_id == user_api_key_dict.user_id
|
||||
or _get_user_in_team(team_table=team_table, user_id=data.user_id) is None
|
||||
or not is_team_admin(user_api_key_dict=user_api_key_dict, team_obj=team_table)
|
||||
)
|
||||
|
||||
|
||||
def _member_key_request(data: GenerateKeyRequest, acting_as_team_admin: bool) -> GenerateKeyRequest:
|
||||
if not acting_as_team_admin:
|
||||
return data
|
||||
return data.model_copy(update={"key_type": TEAM_ADMIN_MEMBER_KEY_TYPE})
|
||||
|
||||
|
||||
async def _stored_user_role(prisma_client: PrismaClient | None, user_id: str | None) -> str | None:
|
||||
if prisma_client is None or user_id is None:
|
||||
return None
|
||||
user_row: Final = await _prisma_table(UserRepository(prisma_client, use_writer=True)).find_unique(
|
||||
where={"user_id": user_id}
|
||||
)
|
||||
return user_row.user_role if user_row is not None else None
|
||||
|
||||
|
||||
async def _common_key_generation_helper(
|
||||
data: GenerateKeyRequest,
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
|
|
@ -1229,9 +1263,23 @@ async def _common_key_generation_helper(
|
|||
prisma_client,
|
||||
)
|
||||
|
||||
acting_as_team_admin: Final = _team_admin_requests_member_key(
|
||||
data=data,
|
||||
team_table=team_table,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
)
|
||||
if acting_as_team_admin:
|
||||
team_admin_member_key_creation_or_raise(
|
||||
team_admin_member_key_creation_verdict(
|
||||
_general_settings(),
|
||||
key_type=data.key_type,
|
||||
owner_role=await _stored_user_role(prisma_client, data.user_id),
|
||||
)
|
||||
)
|
||||
common_key_access_checks(
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
data=data,
|
||||
user_id=user_api_key_dict.user_id if acting_as_team_admin else None,
|
||||
llm_router=llm_router,
|
||||
premium_user=premium_user,
|
||||
)
|
||||
|
|
@ -1439,7 +1487,7 @@ async def _common_key_generation_helper(
|
|||
|
||||
data_json = data.model_dump(exclude_unset=True, exclude_none=True)
|
||||
|
||||
data_json = handle_key_type(data, data_json)
|
||||
data_json = handle_key_type(_member_key_request(data, acting_as_team_admin), data_json)
|
||||
|
||||
# Re-check allowed_routes after handle_key_type, since key_type can derive
|
||||
# an elevated bucket (e.g. ["management_routes"]) that wasn't present in
|
||||
|
|
@ -2113,7 +2161,7 @@ async def generate_key_fn(
|
|||
- key_alias: Optional[str] - User defined key alias
|
||||
- key: Optional[str] - User defined key value. Must start with 'sk-' and be at least 16 characters long. If not set, a 16-digit unique sk-key is created for you.
|
||||
- team_id: Optional[str] - The team id of the key
|
||||
- user_id: Optional[str] - The user id of the key
|
||||
- user_id: Optional[str] - The user id of the key. Non-admin callers may only pass their own id, except a team admin creating a key in a team they administer for another member of that team, when a proxy admin has added `create_member_keys` to `team_admin_editable_team_fields`. That member must have the `internal_user` or `internal_user_viewer` role, and the key is always an `llm_api` key.
|
||||
- agent_id: Optional[str] - The agent id associated with the key.
|
||||
- organization_id: Optional[str] - The organization id of the key. If not set, and team_id is set, the organization id will be the same as the team id. If conflict, an error will be raised.
|
||||
- project_id: Optional[str] - The project id of the key. When set, models and max_budget are validated against the project's limits.
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
"""Proxy-wide allow-list of what a team admin may do on the teams they administer: team-settings fields on
|
||||
/team/update, the ``raise_max_budget`` permission that lets a team admin grow the team's ``max_budget`` (never
|
||||
above the organization's budget, never removing the cap), the ``projects`` permission for /project/new and
|
||||
/project/update, and the ``member_key_budgets`` permission for budget fields on other members' keys via
|
||||
/key/update."""
|
||||
/project/update, the ``member_key_budgets`` permission for budget fields on other members' keys via
|
||||
/key/update, and the ``create_member_keys`` permission for creating team keys owned by other members via
|
||||
/key/generate. A key created that way authenticates as its owner, so the owner must be an ordinary internal
|
||||
user and the key is confined to LLM API routes: the team admin who holds its secret gains no management
|
||||
authority through it."""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
|
|
@ -19,6 +22,8 @@ from litellm.models.verification_token import LiteLLM_VerificationToken
|
|||
from litellm.proxy._types import (
|
||||
LiteLLM_ManagementEndpoint_MetadataFields,
|
||||
LiteLLM_ManagementEndpoint_MetadataFields_Premium,
|
||||
LiteLLMKeyType,
|
||||
LitellmUserRoles,
|
||||
UpdateKeyRequest,
|
||||
UpdateTeamRequest,
|
||||
)
|
||||
|
|
@ -30,10 +35,16 @@ SUPPORTED_TEAM_ADMIN_EDITABLE_TEAM_FIELDS: Final[frozenset[str]] = frozenset({"t
|
|||
TEAM_ADMIN_RAISE_MAX_BUDGET_PERMISSION: Final = "raise_max_budget"
|
||||
TEAM_ADMIN_PROJECTS_PERMISSION: Final = "projects"
|
||||
TEAM_ADMIN_MEMBER_KEY_BUDGETS_PERMISSION: Final = "member_key_budgets"
|
||||
TEAM_ADMIN_CREATE_MEMBER_KEYS_PERMISSION: Final = "create_member_keys"
|
||||
TEAM_ADMIN_MEMBER_KEY_OWNER_ROLES: Final[frozenset[str]] = frozenset(
|
||||
{LitellmUserRoles.INTERNAL_USER.value, LitellmUserRoles.INTERNAL_USER_VIEW_ONLY.value}
|
||||
)
|
||||
TEAM_ADMIN_MEMBER_KEY_TYPE: Final = LiteLLMKeyType.LLM_API
|
||||
SUPPORTED_TEAM_ADMIN_PERMISSIONS: Final[frozenset[str]] = SUPPORTED_TEAM_ADMIN_EDITABLE_TEAM_FIELDS | {
|
||||
TEAM_ADMIN_RAISE_MAX_BUDGET_PERMISSION,
|
||||
TEAM_ADMIN_PROJECTS_PERMISSION,
|
||||
TEAM_ADMIN_MEMBER_KEY_BUDGETS_PERMISSION,
|
||||
TEAM_ADMIN_CREATE_MEMBER_KEYS_PERMISSION,
|
||||
}
|
||||
|
||||
# spend is deliberately excluded: the stored row lags the live cross-pod counter, so a value-diff gate
|
||||
|
|
@ -110,6 +121,91 @@ def team_admin_may_edit_member_key_budgets(general_settings: Mapping[str, object
|
|||
)
|
||||
|
||||
|
||||
def team_admin_may_create_member_keys(general_settings: Mapping[str, object]) -> bool:
|
||||
return TEAM_ADMIN_CREATE_MEMBER_KEYS_PERMISSION in resolve_team_admin_editable_fields(
|
||||
general_settings, frozenset({TEAM_ADMIN_CREATE_MEMBER_KEYS_PERMISSION})
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TeamAdminMemberKeyCreationAllowed:
|
||||
kind: Literal["allowed"] = "allowed"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TeamAdminMemberKeyCreationDisabled:
|
||||
kind: Literal["disabled"] = "disabled"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TeamAdminMemberKeyOwnerNotPermitted:
|
||||
owner_role: str | None
|
||||
kind: Literal["owner_not_permitted"] = "owner_not_permitted"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TeamAdminMemberKeyTypeNotPermitted:
|
||||
key_type: LiteLLMKeyType
|
||||
kind: Literal["key_type_not_permitted"] = "key_type_not_permitted"
|
||||
|
||||
|
||||
TeamAdminMemberKeyCreationVerdict: TypeAlias = (
|
||||
TeamAdminMemberKeyCreationAllowed
|
||||
| TeamAdminMemberKeyCreationDisabled
|
||||
| TeamAdminMemberKeyOwnerNotPermitted
|
||||
| TeamAdminMemberKeyTypeNotPermitted
|
||||
)
|
||||
|
||||
|
||||
def team_admin_member_key_creation_verdict(
|
||||
general_settings: Mapping[str, object],
|
||||
key_type: LiteLLMKeyType | None,
|
||||
owner_role: str | None,
|
||||
) -> TeamAdminMemberKeyCreationVerdict:
|
||||
"""``owner_role`` is the stored proxy role of the member the key is for, None when they have no user row."""
|
||||
if not team_admin_may_create_member_keys(general_settings):
|
||||
return TeamAdminMemberKeyCreationDisabled()
|
||||
if owner_role not in TEAM_ADMIN_MEMBER_KEY_OWNER_ROLES:
|
||||
return TeamAdminMemberKeyOwnerNotPermitted(owner_role=owner_role)
|
||||
if key_type not in (None, LiteLLMKeyType.DEFAULT, TEAM_ADMIN_MEMBER_KEY_TYPE):
|
||||
return TeamAdminMemberKeyTypeNotPermitted(key_type=key_type)
|
||||
return TeamAdminMemberKeyCreationAllowed()
|
||||
|
||||
|
||||
def team_admin_member_key_creation_or_raise(verdict: TeamAdminMemberKeyCreationVerdict) -> None:
|
||||
match verdict:
|
||||
case TeamAdminMemberKeyCreationAllowed():
|
||||
return
|
||||
case TeamAdminMemberKeyCreationDisabled():
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=(
|
||||
"Team admins on this proxy cannot create keys for other team members. "
|
||||
f"Ask a proxy admin to enable '{TEAM_ADMIN_CREATE_MEMBER_KEYS_PERMISSION}' "
|
||||
f"under {_SETTINGS_LOCATION}."
|
||||
),
|
||||
)
|
||||
case TeamAdminMemberKeyOwnerNotPermitted(owner_role=owner_role):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=(
|
||||
"Team admins can only create keys for team members whose proxy role is one of "
|
||||
f"{sorted(TEAM_ADMIN_MEMBER_KEY_OWNER_ROLES)}, not '{owner_role}'. "
|
||||
"Ask a proxy admin to create this key."
|
||||
),
|
||||
)
|
||||
case TeamAdminMemberKeyTypeNotPermitted(key_type=key_type):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=(
|
||||
"Keys a team admin creates for another team member are always "
|
||||
f"'{TEAM_ADMIN_MEMBER_KEY_TYPE.value}' keys, not '{key_type.value}'."
|
||||
),
|
||||
)
|
||||
case _:
|
||||
assert_never(verdict)
|
||||
|
||||
|
||||
def team_admin_may_raise_max_budget(general_settings: Mapping[str, object]) -> bool:
|
||||
"""``raise_max_budget`` only takes effect alongside ``max_budget``: a grant to raise a field the team admin
|
||||
may not edit at all is treated as not granted."""
|
||||
|
|
|
|||
|
|
@ -365,6 +365,8 @@ class UISettings(LiteLLMBaseModel):
|
|||
"removes the cap. "
|
||||
"Include 'projects' to let team admins create and update projects for those teams. "
|
||||
"Include 'member_key_budgets' to let team admins update budget fields on keys owned by other members of those teams. "
|
||||
"Include 'create_member_keys' to let team admins create team keys owned by other members of those teams. "
|
||||
"Those keys can only call LLM API routes, and the member must be an internal user. "
|
||||
"Empty means team admins cannot edit team settings or manage projects at all. "
|
||||
"Proxy admins and org admins are not affected."
|
||||
),
|
||||
|
|
|
|||
|
|
@ -71,6 +71,11 @@ class TeamScenario:
|
|||
def member(self) -> str:
|
||||
return self.scenario.member(self.team_id)
|
||||
|
||||
def proxy_admin_member(self) -> str:
|
||||
user_id: Final = self.scenario.user(user_role="proxy_admin")
|
||||
self.gateway.post("/team/member_add", {"team_id": self.team_id, "member": {"role": "user", "user_id": user_id}})
|
||||
return user_id
|
||||
|
||||
def member_key(self) -> str:
|
||||
created: Final = self.gateway.post("/key/generate", {"user_id": self.member(), "team_id": self.team_id})
|
||||
return string_value(created["key"])
|
||||
|
|
@ -247,6 +252,18 @@ ROUTES: Final[tuple[Route, ...]] = (
|
|||
Route("key_generate_team",
|
||||
lambda s: Call("POST", "/key/generate", {"team_id": s.team_id}),
|
||||
team_admin=200, others=400, member=401, cleanup=_delete_key),
|
||||
Route("key_generate_for_member",
|
||||
lambda s: Call("POST", "/key/generate", {"team_id": s.team_id, "user_id": s.member()}),
|
||||
team_admin=403, others=400, member=401, cleanup=_delete_key),
|
||||
Route("key_generate_for_member_permitted",
|
||||
lambda s: Call("POST", "/key/generate", {"team_id": s.team_id, "user_id": s.member()}),
|
||||
team_admin=200, others=400, member=401, permission=("create_member_keys",), cleanup=_delete_key),
|
||||
Route("key_generate_for_proxy_admin_member_permitted",
|
||||
lambda s: Call("POST", "/key/generate", {"team_id": s.team_id, "user_id": s.proxy_admin_member()}),
|
||||
team_admin=403, others=400, member=401, permission=("create_member_keys",), cleanup=_delete_key),
|
||||
Route("key_generate_management_key_for_member_permitted",
|
||||
lambda s: Call("POST", "/key/generate", {"team_id": s.team_id, "user_id": s.member(), "key_type": "management"}),
|
||||
team_admin=403, others=400, member=401, permission=("create_member_keys",), cleanup=_delete_key),
|
||||
Route("service_account_generate",
|
||||
lambda s: Call("POST", "/key/service-account/generate", {"team_id": s.team_id, "key_alias": f"matrix-{uuid.uuid4().hex}"}),
|
||||
team_admin=200, others=400, member=401, cleanup=_delete_key),
|
||||
|
|
@ -445,3 +462,26 @@ def test_status_code(shared: TeamScenario, org_team: TeamScenario, route: Route,
|
|||
) == [{"max_budget": _BUDGET_ROUTES[route.name] if response.status_code == 200 else _BUDGET_BEFORE}]
|
||||
if response.status_code == 200 and route.cleanup is not None:
|
||||
route.cleanup(s, object_value(response.json()))
|
||||
|
||||
|
||||
def test_member_key_created_by_a_team_admin_reaches_llm_routes_only(shared: TeamScenario) -> None:
|
||||
"""The team admin holds the secret of a key that authenticates as the member, so it must not carry the
|
||||
member's management access."""
|
||||
with shared.gateway.scenario() as scenario:
|
||||
s: Final = replace(shared, scenario=scenario)
|
||||
scenario.cleanups.enter_context(team_admin_permissions(s.gateway, ("create_member_keys",)))
|
||||
member: Final = s.member()
|
||||
created: Final = s.gateway.post(
|
||||
"/key/generate", {"team_id": s.team_id, "user_id": member}, key=s.keys["team_admin"]
|
||||
)
|
||||
token: Final = string_value(created["key"])
|
||||
scenario.cleanups.callback(delete_key_if_present, s.gateway, token)
|
||||
own_key: Final = scenario.key(user_id=member, team_id=s.team_id)
|
||||
|
||||
assert read_rows(
|
||||
'SELECT user_id, team_id, allowed_routes FROM "LiteLLM_VerificationToken" WHERE token = %s',
|
||||
(string_value(created["token"]),),
|
||||
) == [{"user_id": member, "team_id": s.team_id, "allowed_routes": ["llm_api_routes"]}]
|
||||
s.gateway.chat(s.model(), key=token)
|
||||
assert s.gateway.request("GET", "/user/info", key=own_key).status_code == 200
|
||||
assert s.gateway.request("GET", "/user/info", key=token).status_code == 403
|
||||
|
|
|
|||
|
|
@ -61,6 +61,8 @@ from litellm.proxy.management_endpoints.key_management_endpoints import (
|
|||
_personal_key_generation_check,
|
||||
_team_key_generation_check,
|
||||
_common_key_generation_helper,
|
||||
_member_key_request,
|
||||
_team_admin_requests_member_key,
|
||||
KeyProjectBindingError,
|
||||
_effective_key_after_update,
|
||||
_effective_key_for_generate,
|
||||
|
|
@ -22336,6 +22338,99 @@ class TestTeamAdminMemberKeyBudgetUpdate:
|
|||
assert "member_key_budgets" not in str(exc.value.detail)
|
||||
|
||||
|
||||
_MEMBER_KEY_TEAM = LiteLLM_TeamTableCachedObj(
|
||||
team_id="team-1",
|
||||
members_with_roles=[
|
||||
Member(user_id="team-admin-1", role="admin"),
|
||||
Member(user_id="member-1", role="user"),
|
||||
Member(user_id="member-2", role="user"),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def test_team_admin_asking_for_a_teammates_team_key_is_a_member_key_request():
|
||||
assert _team_admin_requests_member_key(
|
||||
data=GenerateKeyRequest(user_id="member-1", team_id="team-1"),
|
||||
team_table=_MEMBER_KEY_TEAM,
|
||||
user_api_key_dict=UserAPIKeyAuth(user_id="team-admin-1", user_role=LitellmUserRoles.INTERNAL_USER),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"data, team_table, caller, caller_role",
|
||||
[
|
||||
pytest.param(
|
||||
GenerateKeyRequest(user_id="member-1", team_id="team-1"),
|
||||
_MEMBER_KEY_TEAM,
|
||||
"member-2",
|
||||
LitellmUserRoles.INTERNAL_USER,
|
||||
id="ordinary_member_for_another_member",
|
||||
),
|
||||
pytest.param(
|
||||
GenerateKeyRequest(user_id="outsider", team_id="team-1"),
|
||||
_MEMBER_KEY_TEAM,
|
||||
"team-admin-1",
|
||||
LitellmUserRoles.INTERNAL_USER,
|
||||
id="team_admin_for_a_user_outside_the_team",
|
||||
),
|
||||
pytest.param(
|
||||
GenerateKeyRequest(user_id="member-1"),
|
||||
None,
|
||||
"team-admin-1",
|
||||
LitellmUserRoles.INTERNAL_USER,
|
||||
id="personal_key_for_another_user",
|
||||
),
|
||||
pytest.param(
|
||||
GenerateKeyRequest(user_id="team-admin-1", team_id="team-1"),
|
||||
_MEMBER_KEY_TEAM,
|
||||
"team-admin-1",
|
||||
LitellmUserRoles.INTERNAL_USER,
|
||||
id="team_admin_for_themselves",
|
||||
),
|
||||
pytest.param(
|
||||
GenerateKeyRequest(team_id="team-1"),
|
||||
_MEMBER_KEY_TEAM,
|
||||
"team-admin-1",
|
||||
LitellmUserRoles.INTERNAL_USER,
|
||||
id="team_key_with_no_owner",
|
||||
),
|
||||
pytest.param(
|
||||
GenerateKeyRequest(user_id="member-1", team_id="team-1"),
|
||||
_MEMBER_KEY_TEAM,
|
||||
"team-admin-1",
|
||||
LitellmUserRoles.PROXY_ADMIN,
|
||||
id="proxy_admin_keeps_the_proxy_admin_path",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_other_key_requests_are_not_member_key_requests(data, team_table, caller, caller_role):
|
||||
assert not _team_admin_requests_member_key(
|
||||
data=data,
|
||||
team_table=team_table,
|
||||
user_api_key_dict=UserAPIKeyAuth(user_id=caller, user_role=caller_role),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("requested", [None, LiteLLMKeyType.DEFAULT, LiteLLMKeyType.LLM_API])
|
||||
def test_a_member_key_is_always_saved_as_an_llm_api_key(requested):
|
||||
from litellm.proxy.management_endpoints.key_management_endpoints import handle_key_type
|
||||
|
||||
request = _member_key_request(
|
||||
GenerateKeyRequest(user_id="member-1", team_id="team-1", key_type=requested), acting_as_team_admin=True
|
||||
)
|
||||
|
||||
assert (request.user_id, request.team_id) == ("member-1", "team-1")
|
||||
assert handle_key_type(request, {}) == {"key_type": "llm_api", "allowed_routes": ["llm_api_routes"]}
|
||||
|
||||
|
||||
def test_other_key_requests_keep_the_key_type_they_asked_for():
|
||||
from litellm.proxy.management_endpoints.key_management_endpoints import handle_key_type
|
||||
|
||||
request = _member_key_request(GenerateKeyRequest(key_type=LiteLLMKeyType.MANAGEMENT), acting_as_team_admin=False)
|
||||
|
||||
assert handle_key_type(request, {}) == {"key_type": "management", "allowed_routes": ["management_routes"]}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rotate_master_key_reencrypts_guardrail_params(monkeypatch):
|
||||
import json
|
||||
|
|
|
|||
|
|
@ -5,7 +5,13 @@ from fastapi import HTTPException
|
|||
|
||||
from litellm.models.team import BudgetLimitEntry
|
||||
from litellm.models.verification_token import LiteLLM_VerificationToken
|
||||
from litellm.proxy._types import LiteLLM_ModelTable, LiteLLM_TeamTable, UpdateKeyRequest, UpdateTeamRequest
|
||||
from litellm.proxy._types import (
|
||||
LiteLLM_ModelTable,
|
||||
LiteLLM_TeamTable,
|
||||
LiteLLMKeyType,
|
||||
UpdateKeyRequest,
|
||||
UpdateTeamRequest,
|
||||
)
|
||||
from litellm.proxy.management_endpoints.team_admin_field_permissions import (
|
||||
TeamAdminEditAllowed,
|
||||
TeamAdminEditingDisabled,
|
||||
|
|
@ -18,9 +24,16 @@ from litellm.proxy.management_endpoints.team_admin_field_permissions import (
|
|||
team_admin_edit_verdict,
|
||||
team_admin_key_edit_verdict,
|
||||
team_admin_key_request_or_raise,
|
||||
team_admin_may_create_member_keys,
|
||||
team_admin_may_edit_member_key_budgets,
|
||||
team_admin_may_manage_projects,
|
||||
team_admin_may_raise_max_budget,
|
||||
team_admin_member_key_creation_or_raise,
|
||||
team_admin_member_key_creation_verdict,
|
||||
TeamAdminMemberKeyCreationAllowed,
|
||||
TeamAdminMemberKeyCreationDisabled,
|
||||
TeamAdminMemberKeyOwnerNotPermitted,
|
||||
TeamAdminMemberKeyTypeNotPermitted,
|
||||
team_admin_request_or_raise,
|
||||
)
|
||||
|
||||
|
|
@ -218,6 +231,65 @@ class TestTeamAdminMayEditMemberKeyBudgets:
|
|||
assert team_admin_may_edit_member_key_budgets({"team_admin_editable_team_fields": raw}) is False
|
||||
|
||||
|
||||
class TestTeamAdminMayCreateMemberKeys:
|
||||
def test_missing_setting_denies(self):
|
||||
assert team_admin_may_create_member_keys({}) is False
|
||||
|
||||
def test_other_permissions_do_not_grant(self):
|
||||
configured = {"team_admin_editable_team_fields": ["tpm_limit", "projects", "member_key_budgets"]}
|
||||
assert team_admin_may_create_member_keys(configured) is False
|
||||
|
||||
def test_create_member_keys_entry_grants(self):
|
||||
configured = {"team_admin_editable_team_fields": ["create_member_keys"]}
|
||||
assert team_admin_may_create_member_keys(configured) is True
|
||||
|
||||
@pytest.mark.parametrize("raw", ["create_member_keys", 7, [1, 2]])
|
||||
def test_malformed_setting_denies(self, raw):
|
||||
assert team_admin_may_create_member_keys({"team_admin_editable_team_fields": raw}) is False
|
||||
|
||||
|
||||
_MEMBER_KEYS_ON = {"team_admin_editable_team_fields": ["create_member_keys"]}
|
||||
|
||||
|
||||
class TestTeamAdminMemberKeyCreationVerdict:
|
||||
@pytest.mark.parametrize("owner_role", ["internal_user", "internal_user_viewer"])
|
||||
@pytest.mark.parametrize("key_type", [None, LiteLLMKeyType.DEFAULT, LiteLLMKeyType.LLM_API])
|
||||
def test_an_ordinary_member_and_an_llm_key_are_allowed(self, owner_role, key_type):
|
||||
verdict = team_admin_member_key_creation_verdict(_MEMBER_KEYS_ON, key_type=key_type, owner_role=owner_role)
|
||||
assert verdict == TeamAdminMemberKeyCreationAllowed()
|
||||
assert team_admin_member_key_creation_or_raise(verdict) is None
|
||||
|
||||
def test_disabled_is_a_403_naming_the_permission_and_where_to_enable_it(self):
|
||||
verdict = team_admin_member_key_creation_verdict(
|
||||
{"team_admin_editable_team_fields": ["member_key_budgets"]}, key_type=None, owner_role="internal_user"
|
||||
)
|
||||
assert verdict == TeamAdminMemberKeyCreationDisabled()
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
team_admin_member_key_creation_or_raise(verdict)
|
||||
assert exc.value.status_code == 403
|
||||
assert "'create_member_keys'" in exc.value.detail
|
||||
assert "Settings > UI > Team admin editable fields" in exc.value.detail
|
||||
|
||||
@pytest.mark.parametrize("owner_role", ["proxy_admin", "proxy_admin_viewer", "org_admin", "team", "customer", None])
|
||||
def test_an_owner_who_is_not_an_ordinary_internal_user_is_refused(self, owner_role):
|
||||
"""A key authenticates with its owner's role, so a privileged owner would hand that role to the team admin."""
|
||||
verdict = team_admin_member_key_creation_verdict(_MEMBER_KEYS_ON, key_type=None, owner_role=owner_role)
|
||||
assert verdict == TeamAdminMemberKeyOwnerNotPermitted(owner_role=owner_role)
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
team_admin_member_key_creation_or_raise(verdict)
|
||||
assert exc.value.status_code == 403
|
||||
assert f"'{owner_role}'" in exc.value.detail
|
||||
|
||||
@pytest.mark.parametrize("key_type", [LiteLLMKeyType.MANAGEMENT, LiteLLMKeyType.READ_ONLY])
|
||||
def test_a_key_type_that_reaches_beyond_llm_routes_is_refused(self, key_type):
|
||||
verdict = team_admin_member_key_creation_verdict(_MEMBER_KEYS_ON, key_type=key_type, owner_role="internal_user")
|
||||
assert verdict == TeamAdminMemberKeyTypeNotPermitted(key_type=key_type)
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
team_admin_member_key_creation_or_raise(verdict)
|
||||
assert exc.value.status_code == 403
|
||||
assert f"'{key_type.value}'" in exc.value.detail
|
||||
|
||||
|
||||
class TestTeamAdminMayRaiseMaxBudget:
|
||||
def test_missing_setting_denies(self):
|
||||
assert team_admin_may_raise_max_budget({}) is False
|
||||
|
|
|
|||
|
|
@ -4022,6 +4022,28 @@ class TestTeamAdminEditableTeamFieldsSetting:
|
|||
assert stored["team_admin_editable_team_fields"] == ["member_key_budgets"]
|
||||
assert team_admin_may_edit_member_key_budgets(general_settings) is True
|
||||
|
||||
def test_patch_accepts_the_create_member_keys_permission(self, monkeypatch):
|
||||
from litellm.proxy.management_endpoints.team_admin_field_permissions import (
|
||||
team_admin_may_create_member_keys,
|
||||
)
|
||||
|
||||
mock_prisma = self._as_proxy_admin(monkeypatch)
|
||||
general_settings: dict = {}
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.general_settings", general_settings)
|
||||
assert team_admin_may_create_member_keys(general_settings) is False
|
||||
|
||||
try:
|
||||
response = client.patch(
|
||||
"/update/ui_settings", json={"team_admin_editable_team_fields": ["create_member_keys"]}
|
||||
)
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
assert response.status_code == 200
|
||||
stored = json.loads(mock_prisma.db.litellm_uisettings.upsert.call_args.kwargs["data"]["create"]["ui_settings"])
|
||||
assert stored["team_admin_editable_team_fields"] == ["create_member_keys"]
|
||||
assert team_admin_may_create_member_keys(general_settings) is True
|
||||
|
||||
def test_patch_with_an_empty_list_turns_team_admin_editing_off_again(self, monkeypatch):
|
||||
mock_prisma = self._as_proxy_admin(monkeypatch)
|
||||
general_settings: dict = {"team_admin_editable_team_fields": ["tpm_limit"]}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,164 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { Member } from "../networking";
|
||||
import {
|
||||
administersTeam,
|
||||
anotherUserScope,
|
||||
effectiveKeyOwner,
|
||||
isTeamMemberOwner,
|
||||
teamMemberOwnerOptions,
|
||||
teamMemberOwnerPlaceholder,
|
||||
type TeamMembers,
|
||||
} from "./createKeyOwnerAccess";
|
||||
|
||||
const VIEWER = "viewer";
|
||||
|
||||
const member = (user_id: string | null, role: string, user_email: string | null = null): Member => ({
|
||||
user_id,
|
||||
role,
|
||||
user_email,
|
||||
});
|
||||
|
||||
const administered: TeamMembers & { team_id: string } = {
|
||||
team_id: "administered",
|
||||
members_with_roles: [member(VIEWER, "admin"), member("alice", "user", "alice@example.com"), member("bob", "user")],
|
||||
};
|
||||
|
||||
const joined: TeamMembers & { team_id: string } = {
|
||||
team_id: "joined",
|
||||
members_with_roles: [member(VIEWER, "user"), member("carol", "admin", "carol@example.com")],
|
||||
};
|
||||
|
||||
const foreign: TeamMembers & { team_id: string } = {
|
||||
team_id: "foreign",
|
||||
members_with_roles: [member("dave", "admin")],
|
||||
};
|
||||
|
||||
const viewer = {
|
||||
userRole: "Internal User",
|
||||
userId: VIEWER,
|
||||
teamAdminEditableFields: ["create_member_keys"],
|
||||
teams: [joined, administered],
|
||||
};
|
||||
|
||||
describe("anotherUserScope", () => {
|
||||
it("lets a team admin pick among team members once the proxy enables create_member_keys", () => {
|
||||
expect(anotherUserScope(viewer)).toBe("team_members");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["nothing is enabled", []],
|
||||
["only other team admin permissions are enabled", ["max_budget", "member_key_budgets", "projects"]],
|
||||
])("hides Another User from a team admin when %s", (_label, teamAdminEditableFields) => {
|
||||
expect(anotherUserScope({ ...viewer, teamAdminEditableFields })).toBe("none");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["only a plain member of their teams", [joined]],
|
||||
["an admin of no listed team", [foreign]],
|
||||
["in no team", []],
|
||||
["still waiting on the team list", null],
|
||||
])("hides Another User with the permission on from a user who is %s", (_label, teams) => {
|
||||
expect(anotherUserScope({ ...viewer, teams })).toBe("none");
|
||||
});
|
||||
|
||||
it("hides Another User when the session carries no user id", () => {
|
||||
expect(anotherUserScope({ ...viewer, userId: null })).toBe("none");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["with the permission off and no teams", { teamAdminEditableFields: [], teams: [] }],
|
||||
["with the permission on and an administered team", {}],
|
||||
["before the team list has loaded", { teams: null }],
|
||||
])("lets a proxy admin pick any user %s", (_label, overrides) => {
|
||||
expect(anotherUserScope({ ...viewer, userRole: "Admin", ...overrides })).toBe("any_user");
|
||||
});
|
||||
});
|
||||
|
||||
describe("effectiveKeyOwner", () => {
|
||||
it.each(["any_user", "team_members"] as const)("keeps another_user for a viewer scoped to %s", (scope) => {
|
||||
expect(effectiveKeyOwner("another_user", scope)).toBe("another_user");
|
||||
});
|
||||
|
||||
it("falls back to you when another_user is requested by a viewer who may not pick one", () => {
|
||||
expect(effectiveKeyOwner("another_user", "none")).toBe("you");
|
||||
});
|
||||
|
||||
it.each(["you", "service_account", "agent"])(
|
||||
"leaves %s alone for a viewer who may not pick another user",
|
||||
(owner) => {
|
||||
expect(effectiveKeyOwner(owner, "none")).toBe(owner);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("administersTeam", () => {
|
||||
it("narrows a team list to the teams the viewer administers", () => {
|
||||
const teams = [joined, administered, foreign];
|
||||
|
||||
expect(teams.filter((team) => administersTeam(team, VIEWER)).map((team) => team.team_id)).toStrictEqual([
|
||||
"administered",
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["no team", null],
|
||||
["a team with no member list", {}],
|
||||
["a team whose member list is null", { members_with_roles: null }],
|
||||
])("is false for %s", (_label, team) => {
|
||||
expect(administersTeam(team, VIEWER)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("teamMemberOwnerOptions", () => {
|
||||
it("lists the selected team's other members, labelled by email when the team has one", () => {
|
||||
expect(teamMemberOwnerOptions(administered, VIEWER)).toStrictEqual([
|
||||
{ value: "alice", label: "alice@example.com (alice)" },
|
||||
{ value: "bob", label: "bob" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("skips a member row that has no user id", () => {
|
||||
const team = {
|
||||
members_with_roles: [member(VIEWER, "admin"), member(null, "user", "pending@example.com"), member("bob", "user")],
|
||||
};
|
||||
|
||||
expect(teamMemberOwnerOptions(team, VIEWER)).toStrictEqual([{ value: "bob", label: "bob" }]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["no team is selected", null],
|
||||
["the selected team is one the viewer only belongs to", joined],
|
||||
["the selected team is one the viewer is not in", foreign],
|
||||
])("offers nobody when %s", (_label, team) => {
|
||||
expect(teamMemberOwnerOptions(team, VIEWER)).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("teamMemberOwnerPlaceholder", () => {
|
||||
const soloTeam = { members_with_roles: [member(VIEWER, "admin")] };
|
||||
|
||||
it.each([
|
||||
["asks for a team before one is selected", null, "Select a team first"],
|
||||
["says so when the viewer is the team's only member", soloTeam, "This team has no other members"],
|
||||
["asks for a member once the team has someone to pick", administered, "Select a member of the team"],
|
||||
])("%s", (_label, team, expected) => {
|
||||
expect(teamMemberOwnerPlaceholder(teamMemberOwnerOptions(team, VIEWER), team)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isTeamMemberOwner", () => {
|
||||
it("keeps an owner who belongs to the selected team", () => {
|
||||
expect(isTeamMemberOwner("alice", administered, VIEWER)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["is in another team only", "carol", administered],
|
||||
["was picked before the team was cleared", "alice", null],
|
||||
["belongs to a team the viewer does not administer", "carol", joined],
|
||||
["is the viewer, who already has the You option", VIEWER, administered],
|
||||
["is not set", null, administered],
|
||||
])("rejects an owner who %s", (_label, ownerId, team) => {
|
||||
expect(isTeamMemberOwner(ownerId, team, VIEWER)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
import type { SearchSelectOption } from "@/components/shared/SearchSelect";
|
||||
import { TEAM_ADMIN_CREATE_MEMBER_KEYS_PERMISSION } from "@/components/team/teamAdminEditAccess";
|
||||
import { isProxyAdminRole, isUserTeamAdminForSingleTeam } from "@/utils/roles";
|
||||
import type { Member } from "../networking";
|
||||
|
||||
export type AnotherUserScope = "any_user" | "team_members" | "none";
|
||||
|
||||
export interface TeamMembers {
|
||||
readonly members_with_roles?: Member[] | null;
|
||||
}
|
||||
|
||||
export interface KeyOwnerViewer {
|
||||
readonly userRole: string | null;
|
||||
readonly userId: string | null;
|
||||
readonly teamAdminEditableFields: readonly string[];
|
||||
readonly teams: readonly TeamMembers[] | null;
|
||||
}
|
||||
|
||||
export const administersTeam = (team: TeamMembers | null, userId: string | null): boolean =>
|
||||
userId !== null && isUserTeamAdminForSingleTeam(team?.members_with_roles ?? null, userId);
|
||||
|
||||
export const anotherUserScope = ({
|
||||
userRole,
|
||||
userId,
|
||||
teamAdminEditableFields,
|
||||
teams,
|
||||
}: KeyOwnerViewer): AnotherUserScope => {
|
||||
if (isProxyAdminRole(userRole ?? "")) return "any_user";
|
||||
if (!teamAdminEditableFields.includes(TEAM_ADMIN_CREATE_MEMBER_KEYS_PERMISSION)) return "none";
|
||||
return (teams ?? []).some((team) => administersTeam(team, userId)) ? "team_members" : "none";
|
||||
};
|
||||
|
||||
export const effectiveKeyOwner = (requestedOwner: string, scope: AnotherUserScope): string =>
|
||||
requestedOwner === "another_user" && scope === "none" ? "you" : requestedOwner;
|
||||
|
||||
export const teamMemberOwnerOptions = (team: TeamMembers | null, viewerId: string | null): SearchSelectOption[] => {
|
||||
if (!administersTeam(team, viewerId)) return [];
|
||||
return (team?.members_with_roles ?? []).flatMap((member) =>
|
||||
member.user_id && member.user_id !== viewerId
|
||||
? [
|
||||
{
|
||||
value: member.user_id,
|
||||
label: member.user_email ? `${member.user_email} (${member.user_id})` : member.user_id,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
);
|
||||
};
|
||||
|
||||
export const teamMemberOwnerPlaceholder = (
|
||||
options: readonly SearchSelectOption[],
|
||||
team: TeamMembers | null,
|
||||
): string => {
|
||||
if (options.length > 0) return "Select a member of the team";
|
||||
return team ? "This team has no other members" : "Select a team first";
|
||||
};
|
||||
|
||||
export const isTeamMemberOwner = (ownerId: unknown, team: TeamMembers | null, viewerId: string | null): boolean =>
|
||||
teamMemberOwnerOptions(team, viewerId).some((option) => option.value === ownerId);
|
||||
|
|
@ -649,6 +649,59 @@ describe("CreateKey", () => {
|
|||
expect(screen.queryByRole("radio", { name: "Another User" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe("for a team admin", () => {
|
||||
const administeredTeam = {
|
||||
team_id: "team-admin-of",
|
||||
team_alias: "Administered Team",
|
||||
models: [],
|
||||
members_with_roles: [
|
||||
{ user_id: "test-user-id", role: "admin" },
|
||||
{ user_id: "member-1", role: "user", user_email: "member@example.com" },
|
||||
],
|
||||
};
|
||||
const joinedTeam = {
|
||||
team_id: "team-member-of",
|
||||
team_alias: "Joined Team",
|
||||
models: [],
|
||||
members_with_roles: [
|
||||
{ user_id: "test-user-id", role: "user" },
|
||||
{ user_id: "outsider-1", role: "admin", user_email: "outsider@example.com" },
|
||||
],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
state.authorized = { ...state.authorized, userRole: "Internal User" };
|
||||
state.teams = [joinedTeam, administeredTeam];
|
||||
});
|
||||
|
||||
it("hides the Another User option until the proxy enables create_member_keys", async () => {
|
||||
state.uiSettings = { team_admin_editable_team_fields: ["max_budget"] };
|
||||
await openModal({ teams: state.teams as unknown as Team[] });
|
||||
|
||||
expect(screen.queryByRole("radio", { name: "Another User" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("creates a key for a member of an administered team without the proxy-wide user search", async () => {
|
||||
state.uiSettings = { team_admin_editable_team_fields: ["create_member_keys"] };
|
||||
await openModal({ teams: state.teams as unknown as Team[] });
|
||||
await nameTheKey();
|
||||
await userEvent.click(screen.getByRole("radio", { name: "Another User" }));
|
||||
|
||||
await userEvent.click(await screen.findByLabelText(/^Team/));
|
||||
expect(await screen.findByRole("option", { name: /Administered Team/ })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("option", { name: /Joined Team/ })).not.toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole("option", { name: /Administered Team/ }));
|
||||
|
||||
await userEvent.click(await screen.findByLabelText(/^User ID/));
|
||||
expect(await screen.findAllByRole("option")).toHaveLength(1);
|
||||
await userEvent.click(screen.getByRole("option", { name: "member@example.com (member-1)" }));
|
||||
await submit();
|
||||
|
||||
expect(await createdPayload()).toMatchObject({ user_id: "member-1", team_id: "team-admin-of" });
|
||||
expect(userFilterUICall).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("routes a service account through the service account endpoint and stamps the alias into metadata", async () => {
|
||||
state.teams = [{ team_id: "team-1", team_alias: "Team One", models: [] }];
|
||||
await openModal({ teams: state.teams as unknown as Team[] });
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { useTags } from "@/app/(dashboard)/hooks/tags/useTags";
|
|||
import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import useCan from "@/app/(dashboard)/hooks/useCan";
|
||||
import { parseTeamAdminEditableFields } from "@/components/team/teamAdminEditAccess";
|
||||
import { formatNumberWithCommas } from "@/utils/dataUtils";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -80,6 +81,14 @@ import {
|
|||
import CreatedKeyDisplay from "../shared/CreatedKeyDisplay";
|
||||
import NumericalInput from "../shared/numerical_input";
|
||||
import VectorStoreSelector from "../vector_store_management/VectorStoreSelector";
|
||||
import {
|
||||
administersTeam,
|
||||
anotherUserScope,
|
||||
effectiveKeyOwner,
|
||||
isTeamMemberOwner,
|
||||
teamMemberOwnerOptions,
|
||||
teamMemberOwnerPlaceholder,
|
||||
} from "./createKeyOwnerAccess";
|
||||
import { buildKeyCreatePayload, type KeyCreateInput } from "./createKeyPayload";
|
||||
import { simplifyKeyGenerateError } from "./utils";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
|
|
@ -248,7 +257,7 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
|
|||
const [apiKey, setApiKey] = useState(null);
|
||||
const [userModels, setUserModels] = useState<string[]>([]);
|
||||
const [modelsToPick, setModelsToPick] = useState<string[]>([]);
|
||||
const [keyOwner, setKeyOwner] = useState("you");
|
||||
const [requestedKeyOwner, setKeyOwner] = useState("you");
|
||||
const [hasPrefilled, setHasPrefilled] = useState(false);
|
||||
const [pendingPrefillModels, setPendingPrefillModels] = useState<string[] | null>(null);
|
||||
const [guardrailsList, setGuardrailsList] = useState<string[]>([]);
|
||||
|
|
@ -279,6 +288,13 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
|
|||
const [agentsList, setAgentsList] = useState<{ agent_id: string; agent_name: string }[]>([]);
|
||||
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
|
||||
const selectedModels: string[] = (useWatch({ control: form.control, name: "models" }) as string[] | undefined) ?? [];
|
||||
const teamAdminEditableFields = parseTeamAdminEditableFields(uiSettingsData?.values);
|
||||
const keyOwnerViewer = { userRole, userId: userID, teamAdminEditableFields, teams };
|
||||
const ownerScope = anotherUserScope(keyOwnerViewer);
|
||||
const keyOwner = effectiveKeyOwner(requestedKeyOwner, ownerScope);
|
||||
const ownerLimitedToTeamMembers = keyOwner === "another_user" && ownerScope === "team_members";
|
||||
const memberOwnerOptions = teamMemberOwnerOptions(selectedCreateKeyTeam, userID);
|
||||
const ownerRequiresTeam = keyOwner === "service_account" || ownerLimitedToTeamMembers;
|
||||
const handleCancel = () => {
|
||||
setIsModalVisible(false);
|
||||
setApiKey(null);
|
||||
|
|
@ -383,14 +399,8 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
|
|||
|
||||
// Apply prefill data if provided
|
||||
if (prefillData) {
|
||||
// Set key owner (owned_by) - validate that "another_user" is only allowed for Admin
|
||||
if (prefillData.owned_by) {
|
||||
if (prefillData.owned_by === "another_user" && userRole !== "Admin") {
|
||||
// Ignore invalid owned_by for non-admin users, fall back to default
|
||||
setKeyOwner("you");
|
||||
} else {
|
||||
setKeyOwner(prefillData.owned_by);
|
||||
}
|
||||
setKeyOwner(prefillData.owned_by);
|
||||
}
|
||||
|
||||
// Set team - find the team by ID and set it (only if team exists in user's teams)
|
||||
|
|
@ -616,6 +626,23 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
|
|||
}
|
||||
};
|
||||
|
||||
const changeKeyOwner = (owner: string) => {
|
||||
setKeyOwner(owner);
|
||||
const limitsOwnerToTeamMembers = owner === "another_user" && ownerScope === "team_members";
|
||||
if (limitsOwnerToTeamMembers && selectedCreateKeyTeam && !administersTeam(selectedCreateKeyTeam, userID)) {
|
||||
selectTeam(null);
|
||||
form.setValue("team_id", null);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!ownerLimitedToTeamMembers) return;
|
||||
const owner = form.getValues("user_id");
|
||||
if (owner != null && !isTeamMemberOwner(owner, selectedCreateKeyTeam, userID)) {
|
||||
form.setValue("user_id", null);
|
||||
}
|
||||
}, [ownerLimitedToTeamMembers, selectedCreateKeyTeam, userID, form]);
|
||||
|
||||
const changeProject = (write: FieldWrite) => (projectId: string | null) => {
|
||||
write(projectId);
|
||||
if (!projectId) {
|
||||
|
|
@ -650,6 +677,68 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
|
|||
}
|
||||
};
|
||||
|
||||
const memberOwnerPlaceholder = teamMemberOwnerPlaceholder(memberOwnerOptions, selectedCreateKeyTeam);
|
||||
const requiredTeamHelp = ownerLimitedToTeamMembers
|
||||
? "required, the key's owner must be a member of this team"
|
||||
: "required";
|
||||
const teamFieldHelp = ownerRequiresTeam ? requiredTeamHelp : "";
|
||||
const ownerField = keyOwner === "another_user" && (
|
||||
<MountedFormField
|
||||
label={
|
||||
<span>
|
||||
User ID{" "}
|
||||
<SimpleTooltip content="The user who will own this key and be responsible for its usage">
|
||||
<Info className="ml-1 inline size-3.5 align-text-bottom" />
|
||||
</SimpleTooltip>
|
||||
</span>
|
||||
}
|
||||
name="user_id"
|
||||
className="mt-4"
|
||||
required
|
||||
rules={requiredRule(
|
||||
keyOwner === "another_user",
|
||||
`Please input the user ID of the user you are assigning the key to`,
|
||||
)}
|
||||
>
|
||||
{(control) =>
|
||||
ownerLimitedToTeamMembers ? (
|
||||
<SearchSelect
|
||||
inputId={control.id}
|
||||
options={memberOwnerOptions}
|
||||
value={typeof control.value === "string" ? control.value : null}
|
||||
onValueChange={control.onChange}
|
||||
disabled={memberOwnerOptions.length === 0}
|
||||
placeholder={memberOwnerPlaceholder}
|
||||
emptyText="No team members found"
|
||||
/>
|
||||
) : (
|
||||
<div>
|
||||
<div className="mb-2 flex">
|
||||
<PaginatedSearchSelect
|
||||
options={userOptions}
|
||||
value={typeof control.value === "string" ? control.value : undefined}
|
||||
onValueChange={control.onChange}
|
||||
onSearchChange={fetchUsers}
|
||||
isLoading={userSearchLoading}
|
||||
placeholder="Type email or user ID to search for users"
|
||||
emptyText="No users found"
|
||||
loadingText="Searching..."
|
||||
inputId={control.id}
|
||||
aria-required={control["aria-required"] === "true" ? true : undefined}
|
||||
aria-invalid={control["aria-invalid"] === "true" ? true : undefined}
|
||||
aria-describedby={control["aria-describedby"]}
|
||||
/>
|
||||
<Button variant="outline" className="ml-2" onClick={() => setIsCreateUserModalVisible(true)}>
|
||||
Create User
|
||||
</Button>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">Search by email or user ID to find users</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</MountedFormField>
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{userRole && rolesWithWriteAccess.includes(userRole) && (
|
||||
|
|
@ -679,7 +768,7 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
|
|||
<RadioGroup
|
||||
className="flex flex-wrap items-center gap-4"
|
||||
value={keyOwner}
|
||||
onValueChange={(value: unknown) => setKeyOwner(String(value))}
|
||||
onValueChange={(value: unknown) => changeKeyOwner(String(value))}
|
||||
>
|
||||
<label className={KEY_OWNER_LABEL_CLASS}>
|
||||
<RadioGroupItem value="you" />
|
||||
|
|
@ -689,7 +778,7 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
|
|||
<RadioGroupItem value="service_account" />
|
||||
Service Account
|
||||
</label>
|
||||
{userRole === "Admin" && (
|
||||
{ownerScope !== "none" && (
|
||||
<label className={KEY_OWNER_LABEL_CLASS}>
|
||||
<RadioGroupItem value="another_user" />
|
||||
Another User
|
||||
|
|
@ -702,50 +791,7 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
|
|||
</RadioGroup>
|
||||
</Field>
|
||||
|
||||
{keyOwner === "another_user" && (
|
||||
<MountedFormField
|
||||
label={
|
||||
<span>
|
||||
User ID{" "}
|
||||
<SimpleTooltip content="The user who will own this key and be responsible for its usage">
|
||||
<Info className="ml-1 inline size-3.5 align-text-bottom" />
|
||||
</SimpleTooltip>
|
||||
</span>
|
||||
}
|
||||
name="user_id"
|
||||
className="mt-4"
|
||||
required
|
||||
rules={requiredRule(
|
||||
keyOwner === "another_user",
|
||||
`Please input the user ID of the user you are assigning the key to`,
|
||||
)}
|
||||
>
|
||||
{(control) => (
|
||||
<div>
|
||||
<div className="mb-2 flex">
|
||||
<PaginatedSearchSelect
|
||||
options={userOptions}
|
||||
value={typeof control.value === "string" ? control.value : undefined}
|
||||
onValueChange={control.onChange}
|
||||
onSearchChange={fetchUsers}
|
||||
isLoading={userSearchLoading}
|
||||
placeholder="Type email or user ID to search for users"
|
||||
emptyText="No users found"
|
||||
loadingText="Searching..."
|
||||
inputId={control.id}
|
||||
aria-required={control["aria-required"] === "true" ? true : undefined}
|
||||
aria-invalid={control["aria-invalid"] === "true" ? true : undefined}
|
||||
aria-describedby={control["aria-describedby"]}
|
||||
/>
|
||||
<Button variant="outline" className="ml-2" onClick={() => setIsCreateUserModalVisible(true)}>
|
||||
Create User
|
||||
</Button>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">Search by email or user ID to find users</div>
|
||||
</div>
|
||||
)}
|
||||
</MountedFormField>
|
||||
)}
|
||||
{!ownerLimitedToTeamMembers && ownerField}
|
||||
{keyOwner === "agent" && (
|
||||
<div className="mt-4 p-4 bg-purple-50 border border-purple-200 rounded-md dark:bg-purple-950 dark:border-purple-800">
|
||||
<div className="mb-3">
|
||||
|
|
@ -803,9 +849,14 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
|
|||
}
|
||||
name="team_id"
|
||||
className="mt-4"
|
||||
required={keyOwner === "service_account"}
|
||||
rules={requiredRule(keyOwner === "service_account", "Please select a team for the service account")}
|
||||
help={keyOwner === "service_account" ? "required" : ""}
|
||||
required={ownerRequiresTeam}
|
||||
rules={requiredRule(
|
||||
ownerRequiresTeam,
|
||||
ownerLimitedToTeamMembers
|
||||
? "Please select the team the key's owner belongs to"
|
||||
: "Please select a team for the service account",
|
||||
)}
|
||||
help={teamFieldHelp}
|
||||
>
|
||||
{(control) => (
|
||||
<TeamDropdown
|
||||
|
|
@ -815,9 +866,11 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
|
|||
disabled={selectedProjectId !== null}
|
||||
organizationId={selectedOrganizationId}
|
||||
onTeamSelect={selectTeam}
|
||||
filterTeam={ownerLimitedToTeamMembers ? (team) => administersTeam(team, userID) : undefined}
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
{ownerLimitedToTeamMembers && ownerField}
|
||||
{enableProjectsUI && (
|
||||
<MountedFormField
|
||||
label={
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ describe("teamAdminFieldLabel", () => {
|
|||
["max_budget", "Max Budget (USD)"],
|
||||
["raise_max_budget", "Raise the team's max budget"],
|
||||
["projects", "Create and update projects"],
|
||||
["create_member_keys", "Create keys for team members"],
|
||||
])("names %s the way the team settings form does", (field, label) => {
|
||||
expect(teamAdminFieldLabel(field)).toBe(label);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ export const TEAM_ADMIN_EDITABLE_TEAM_FIELDS_SETTING = "team_admin_editable_team
|
|||
|
||||
export const TEAM_ADMIN_RAISE_MAX_BUDGET_PERMISSION = "raise_max_budget";
|
||||
|
||||
export const TEAM_ADMIN_CREATE_MEMBER_KEYS_PERMISSION = "create_member_keys";
|
||||
|
||||
export const TEAM_ADMIN_EDITING_DISABLED_TITLE = "Team admins cannot edit team settings on this proxy";
|
||||
export const TEAM_ADMIN_EDITING_DISABLED_DESCRIPTION =
|
||||
"Ask a proxy admin to enable fields under Settings > UI > Team admin editable fields.";
|
||||
|
|
@ -57,6 +59,7 @@ const TEAM_ADMIN_FIELD_LABELS: ReadonlyMap<string, string> = new Map([
|
|||
[TEAM_ADMIN_RAISE_MAX_BUDGET_PERMISSION, "Raise the team's max budget"],
|
||||
["projects", "Create and update projects"],
|
||||
["member_key_budgets", "Update budgets on team members' keys"],
|
||||
[TEAM_ADMIN_CREATE_MEMBER_KEYS_PERMISSION, "Create keys for team members"],
|
||||
]);
|
||||
|
||||
export const teamAdminFieldLabel = (field: string): string => TEAM_ADMIN_FIELD_LABELS.get(field) ?? field;
|
||||
|
|
|
|||
2
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
2
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -8344,7 +8344,7 @@ export interface paths {
|
|||
* - key_alias: Optional[str] - User defined key alias
|
||||
* - key: Optional[str] - User defined key value. Must start with 'sk-' and be at least 16 characters long. If not set, a 16-digit unique sk-key is created for you.
|
||||
* - team_id: Optional[str] - The team id of the key
|
||||
* - user_id: Optional[str] - The user id of the key
|
||||
* - user_id: Optional[str] - The user id of the key. Non-admin callers may only pass their own id, except a team admin creating a key in a team they administer for another member of that team, when a proxy admin has added `create_member_keys` to `team_admin_editable_team_fields`. That member must have the `internal_user` or `internal_user_viewer` role, and the key is always an `llm_api` key.
|
||||
* - agent_id: Optional[str] - The agent id associated with the key.
|
||||
* - organization_id: Optional[str] - The organization id of the key. If not set, and team_id is set, the organization id will be the same as the team id. If conflict, an error will be raised.
|
||||
* - project_id: Optional[str] - The project id of the key. When set, models and max_budget are validated against the project's limits.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue