From aa879f77a90d470427dd2b49877b03a0de131697 Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Tue, 13 Oct 2026 23:25:58 -0700 Subject: [PATCH] feat(proxy): let team admins create keys for team members behind an opt-in setting (#45870) * feat(proxy): let team admins create keys for team members behind an opt-in setting A team admin calling POST /key/generate with a team they administer and the user_id of another member of that team got a 403, and the dashboard only offered the "Another User" owner option to proxy admins Add a create_member_keys entry to team_admin_editable_team_fields. When a proxy admin turns it on, a team admin can create a team key owned by another member of a team they administer, through the API and through the Create Key form. It stays off by default, so nothing changes until a proxy admin opts in With the setting off, the 403 a team admin gets for this call now names the setting to enable instead of saying they can only create keys for themselves * fix(ui): order the member owner picker after the team and drop the viewer from it For a team admin the owner picker depends on the team, so it now sits under the Team field, lists only the other members of that team, and the Team field says why it is required * fix(proxy): keep keys a team admin creates for a member from carrying more than LLM access A key authenticates as its owner, so with create_member_keys on a team admin could create a key owned by a proxy admin who sits in their team and act as a proxy admin with it The member now has to hold the internal_user or internal_user_viewer role, and the key is always an llm_api key, so the team admin never holds a secret that reaches management routes. Asking for a management or read_only key for a member is refused The unit tests now check the permission decision directly instead of replacing generate_key_helper_fn, and the saved key is checked against a real proxy in the team admin gate matrix * style(proxy): sort the team admin field permission imports * refactor(proxy): force the member key type on the request instead of merging a second dict * test(proxy): drive member key creation through the key generation helper The pure helpers were covered but the wiring in _common_key_generation_helper was not. Dropping the owner role lookup, the refusal call, or the user_id handed to common_key_access_checks left every unit test green * fix(proxy): skip the owner role lookup when create_member_keys is off The owner role was read before the verdict checked the setting, so with the setting off a failing user table read surfaced as a 500 instead of the 403 a team admin got before, and every such request paid an extra writer read --------- Co-authored-by: Yuneng Jiang --- .../key_management_endpoints.py | 58 +++++- .../team_admin_field_permissions.py | 100 ++++++++++- .../proxy_setting_endpoints.py | 2 + .../authorization/test_team_admin_gate.py | 40 +++++ .../test_key_management_endpoints.py | 162 +++++++++++++++++ .../test_team_admin_field_permissions.py | 74 +++++++- .../test_proxy_setting_endpoints.py | 22 +++ .../organisms/createKeyOwnerAccess.test.ts | 164 +++++++++++++++++ .../organisms/createKeyOwnerAccess.ts | 59 +++++++ .../create_key_button.integration.test.tsx | 53 ++++++ .../organisms/create_key_button.tsx | 167 ++++++++++++------ .../team/teamAdminEditAccess.test.ts | 1 + .../components/team/teamAdminEditAccess.ts | 3 + ui/litellm-dashboard/src/lib/http/schema.d.ts | 2 +- 14 files changed, 844 insertions(+), 63 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/organisms/createKeyOwnerAccess.test.ts create mode 100644 ui/litellm-dashboard/src/components/organisms/createKeyOwnerAccess.ts diff --git a/litellm/proxy/management_endpoints/key_management_endpoints.py b/litellm/proxy/management_endpoints/key_management_endpoints.py index a3f65b7315b..896f12cb0c7 100644 --- a/litellm/proxy/management_endpoints/key_management_endpoints.py +++ b/litellm/proxy/management_endpoints/key_management_endpoints.py @@ -112,9 +112,13 @@ 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_create_member_keys, 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, @@ -1216,6 +1220,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, @@ -1230,9 +1265,28 @@ 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: + general_settings: Final = _general_settings() + 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) + if team_admin_may_create_member_keys(general_settings) + else None + ), + ) + ) 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, ) @@ -1440,7 +1494,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 @@ -2114,7 +2168,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. diff --git a/litellm/proxy/management_endpoints/team_admin_field_permissions.py b/litellm/proxy/management_endpoints/team_admin_field_permissions.py index dfae1b623f1..54e303f69b9 100644 --- a/litellm/proxy/management_endpoints/team_admin_field_permissions.py +++ b/litellm/proxy/management_endpoints/team_admin_field_permissions.py @@ -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.""" diff --git a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py index e73507f8f4e..cbfd249eb82 100644 --- a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py +++ b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py @@ -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." ), diff --git a/tests/integration/authorization/test_team_admin_gate.py b/tests/integration/authorization/test_team_admin_gate.py index c3172e27c35..7b4fa52c4e4 100644 --- a/tests/integration/authorization/test_team_admin_gate.py +++ b/tests/integration/authorization/test_team_admin_gate.py @@ -76,6 +76,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"]) @@ -252,6 +257,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), @@ -452,6 +469,29 @@ def test_status_code(shared: TeamScenario, org_team: TeamScenario, route: Route, 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 + + ADMIN_ONLY_CALLERS: Final[tuple[tuple[str, Caller], ...]] = ( ("shared", "team_admin"), ("shared", "member"), diff --git a/tests/unit/proxy/management_endpoints/test_key_management_endpoints.py b/tests/unit/proxy/management_endpoints/test_key_management_endpoints.py index 71334e2321b..1af9def3afb 100644 --- a/tests/unit/proxy/management_endpoints/test_key_management_endpoints.py +++ b/tests/unit/proxy/management_endpoints/test_key_management_endpoints.py @@ -21,6 +21,7 @@ import inspect from litellm.models.project import LiteLLM_ProjectTable from litellm.proxy._types import ( + GenerateKeyResponse, GenerateKeyRequest, KeyManagementRoutes, NewUserRequest, @@ -61,6 +62,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 +22339,165 @@ 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"]} + + +async def _team_admin_generates_member_key( + find_owner: AsyncMock, + generate_key: AsyncMock, + editable_fields: tuple[str, ...] = ("create_member_keys",), +) -> GenerateKeyResponse: + prisma_client: Final = MagicMock() + prisma_client.writer_db.litellm_usertable.find_unique = find_owner + with ( + patch("litellm.proxy.proxy_server.prisma_client", prisma_client), + patch("litellm.proxy.proxy_server.llm_router", None), + patch("litellm.proxy.proxy_server.premium_user", False), + patch("litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin"), + patch( + "litellm.proxy.proxy_server.general_settings", + {"team_admin_editable_team_fields": list(editable_fields)}, + ), + patch( + "litellm.proxy.management_endpoints.key_management_endpoints.generate_key_helper_fn", + generate_key, + ), + ): + return await _common_key_generation_helper( + data=GenerateKeyRequest(user_id="member-1", team_id="team-1"), + user_api_key_dict=UserAPIKeyAuth(user_id="team-admin-1", user_role=LitellmUserRoles.INTERNAL_USER), + litellm_changed_by=None, + team_table=_MEMBER_KEY_TEAM, + ) + + +@pytest.mark.asyncio +async def test_team_admin_creates_an_llm_api_key_owned_by_an_internal_user_teammate(): + generate_key: Final = AsyncMock(return_value={"key": "sk-member", "user_id": "member-1", "team_id": "team-1"}) + + await _team_admin_generates_member_key( + AsyncMock(return_value=MagicMock(user_role=LitellmUserRoles.INTERNAL_USER.value)), generate_key + ) + + saved: Final = generate_key.await_args.kwargs + assert (saved["user_id"], saved["team_id"]) == ("member-1", "team-1") + assert (saved["key_type"], saved["allowed_routes"]) == ("llm_api", ["llm_api_routes"]) + + +@pytest.mark.asyncio +async def test_team_admin_cannot_create_a_key_owned_by_a_proxy_admin_teammate(): + generate_key: Final = AsyncMock() + + with pytest.raises(HTTPException) as exc: + await _team_admin_generates_member_key( + AsyncMock(return_value=MagicMock(user_role=LitellmUserRoles.PROXY_ADMIN.value)), generate_key + ) + + assert exc.value.status_code == 403 + generate_key.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_member_key_request_with_the_setting_off_is_refused_without_reading_the_owner(): + find_owner: Final = AsyncMock(side_effect=ConnectionError("db down")) + + with pytest.raises(HTTPException) as exc: + await _team_admin_generates_member_key(find_owner, AsyncMock(), editable_fields=()) + + assert exc.value.status_code == 403 + find_owner.assert_not_awaited() + + @pytest.mark.asyncio async def test_rotate_master_key_reencrypts_guardrail_params(monkeypatch): import json diff --git a/tests/unit/proxy/management_endpoints/test_team_admin_field_permissions.py b/tests/unit/proxy/management_endpoints/test_team_admin_field_permissions.py index a593431523b..730f4640da8 100644 --- a/tests/unit/proxy/management_endpoints/test_team_admin_field_permissions.py +++ b/tests/unit/proxy/management_endpoints/test_team_admin_field_permissions.py @@ -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 diff --git a/tests/unit/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py b/tests/unit/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py index 62a57af2108..6705e5ac8a5 100644 --- a/tests/unit/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py +++ b/tests/unit/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py @@ -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"]} diff --git a/ui/litellm-dashboard/src/components/organisms/createKeyOwnerAccess.test.ts b/ui/litellm-dashboard/src/components/organisms/createKeyOwnerAccess.test.ts new file mode 100644 index 00000000000..204a2a8fa86 --- /dev/null +++ b/ui/litellm-dashboard/src/components/organisms/createKeyOwnerAccess.test.ts @@ -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); + }); +}); diff --git a/ui/litellm-dashboard/src/components/organisms/createKeyOwnerAccess.ts b/ui/litellm-dashboard/src/components/organisms/createKeyOwnerAccess.ts new file mode 100644 index 00000000000..dcbebab7c25 --- /dev/null +++ b/ui/litellm-dashboard/src/components/organisms/createKeyOwnerAccess.ts @@ -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); diff --git a/ui/litellm-dashboard/src/components/organisms/create_key_button.integration.test.tsx b/ui/litellm-dashboard/src/components/organisms/create_key_button.integration.test.tsx index 63ed7e14b67..0fbe043c0d1 100644 --- a/ui/litellm-dashboard/src/components/organisms/create_key_button.integration.test.tsx +++ b/ui/litellm-dashboard/src/components/organisms/create_key_button.integration.test.tsx @@ -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[] }); diff --git a/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx b/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx index 45245ff95b3..eb8c96a675a 100644 --- a/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx +++ b/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx @@ -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 = ({ team, teams, data, addKey, autoOp const [apiKey, setApiKey] = useState(null); const [userModels, setUserModels] = useState([]); const [modelsToPick, setModelsToPick] = useState([]); - const [keyOwner, setKeyOwner] = useState("you"); + const [requestedKeyOwner, setKeyOwner] = useState("you"); const [hasPrefilled, setHasPrefilled] = useState(false); const [pendingPrefillModels, setPendingPrefillModels] = useState(null); const [guardrailsList, setGuardrailsList] = useState([]); @@ -279,6 +288,13 @@ const CreateKey: React.FC = ({ team, teams, data, addKey, autoOp const [agentsList, setAgentsList] = useState<{ agent_id: string; agent_name: string }[]>([]); const [selectedAgentId, setSelectedAgentId] = useState(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 = ({ 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 = ({ 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 = ({ 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" && ( + + User ID{" "} + + + + + } + 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 ? ( + + ) : ( +
+
+ + +
+
Search by email or user ID to find users
+
+ ) + } +
+ ); + return (
{userRole && rolesWithWriteAccess.includes(userRole) && ( @@ -679,7 +768,7 @@ const CreateKey: React.FC = ({ team, teams, data, addKey, autoOp setKeyOwner(String(value))} + onValueChange={(value: unknown) => changeKeyOwner(String(value))} > - {userRole === "Admin" && ( + {ownerScope !== "none" && ( - {keyOwner === "another_user" && ( - - User ID{" "} - - - - - } - 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) => ( -
-
- - -
-
Search by email or user ID to find users
-
- )} -
- )} + {!ownerLimitedToTeamMembers && ownerField} {keyOwner === "agent" && (
@@ -803,9 +849,14 @@ const CreateKey: React.FC = ({ 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) => ( = ({ team, teams, data, addKey, autoOp disabled={selectedProjectId !== null} organizationId={selectedOrganizationId} onTeamSelect={selectTeam} + filterTeam={ownerLimitedToTeamMembers ? (team) => administersTeam(team, userID) : undefined} /> )} + {ownerLimitedToTeamMembers && ownerField} {enableProjectsUI && ( { ["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); }); diff --git a/ui/litellm-dashboard/src/components/team/teamAdminEditAccess.ts b/ui/litellm-dashboard/src/components/team/teamAdminEditAccess.ts index cd24d6bdff0..1fed045292b 100644 --- a/ui/litellm-dashboard/src/components/team/teamAdminEditAccess.ts +++ b/ui/litellm-dashboard/src/components/team/teamAdminEditAccess.ts @@ -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 = 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; diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 3b018db7f9b..98071b81e6f 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -8347,7 +8347,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.