diff --git a/litellm/proxy/management_endpoints/key_management_endpoints.py b/litellm/proxy/management_endpoints/key_management_endpoints.py index 66d90f8d90d..d1a46108787 100644 --- a/litellm/proxy/management_endpoints/key_management_endpoints.py +++ b/litellm/proxy/management_endpoints/key_management_endpoints.py @@ -114,8 +114,10 @@ from litellm.proxy.management_endpoints.router_weights import validate_router_se from litellm.proxy.management_endpoints.team_admin_field_permissions import ( team_admin_key_edit_verdict, team_admin_key_request_or_raise, + TEAM_ADMIN_MEMBER_KEY_TYPE, 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,26 +1218,35 @@ def _enforce_upperbound_key_params( ) -def _acting_as_team_admin_for_key_generation( +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 creating a team key owned by another member of that team. - - Raises 403 when the caller administers the team but the create_member_keys permission is disabled. - """ - if ( + """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) - ): - return False - team_admin_member_key_creation_or_raise(_general_settings()) - return True + ) + + +def _member_key_route_limit(acting_as_team_admin: bool) -> dict[str, object]: + if not acting_as_team_admin: + return {} + return handle_key_type(GenerateKeyRequest(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( @@ -1252,11 +1263,19 @@ async def _common_key_generation_helper( prisma_client, ) - acting_as_team_admin: Final = _acting_as_team_admin_for_key_generation( + 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, @@ -1468,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(data, data_json), **_member_key_route_limit(acting_as_team_admin)} # 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 @@ -2142,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. 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`. + - 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 d4baffbaf9b..54e303f69b9 100644 --- a/litellm/proxy/management_endpoints/team_admin_field_permissions.py +++ b/litellm/proxy/management_endpoints/team_admin_field_permissions.py @@ -3,7 +3,9 @@ above the organization's budget, never removing the cap), the ``projects`` permission for /project/new and /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.""" +/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 @@ -20,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, ) @@ -32,6 +36,10 @@ 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, @@ -119,17 +127,83 @@ def team_admin_may_create_member_keys(general_settings: Mapping[str, object]) -> ) -def team_admin_member_key_creation_or_raise(general_settings: Mapping[str, object]) -> None: - if team_admin_may_create_member_keys(general_settings): - return - 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}." - ), - ) +@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: diff --git a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py index ee04428f3da..cbfd249eb82 100644 --- a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py +++ b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py @@ -366,6 +366,7 @@ class UISettings(LiteLLMBaseModel): "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 e0679c9bc43..6eb772bdc69 100644 --- a/tests/integration/authorization/test_team_admin_gate.py +++ b/tests/integration/authorization/test_team_admin_gate.py @@ -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"]) @@ -253,6 +258,12 @@ ROUTES: Final[tuple[Route, ...]] = ( 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), @@ -451,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 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 626d084cb82..a25ee7f9003 100644 --- a/tests/unit/proxy/management_endpoints/test_key_management_endpoints.py +++ b/tests/unit/proxy/management_endpoints/test_key_management_endpoints.py @@ -56,6 +56,8 @@ from litellm.proxy.management_endpoints.key_management_endpoints import ( _check_project_key_limits, _check_team_key_limits, _common_key_generation_helper, + _member_key_route_limit, + _team_admin_requests_member_key, KeyProjectBindingError, _effective_key_after_update, _effective_key_for_generate, @@ -22327,97 +22329,85 @@ class TestTeamAdminMemberKeyBudgetUpdate: assert "member_key_budgets" not in str(exc.value.detail) -class TestTeamAdminCreatesMemberKey: - """LIT-6380: a team admin may create a team key owned by another member of that team - only when the proxy enables the 'create_member_keys' permission.""" +_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 _team(self, members): - return LiteLLM_TeamTableCachedObj(team_id="team-1", members_with_roles=members) - def _admin_and_member(self): - return self._team([Member(user_id="team-admin-1", role="admin"), Member(user_id="member-1", role="user")]) - - async def _generate(self, monkeypatch, editable_fields, data, team_table, caller): - generate_key = AsyncMock( - return_value={"key": "sk-new", "expires": None, "user_id": data.user_id, "team_id": data.team_id} - ) - monkeypatch.setattr( - "litellm.proxy.proxy_server.general_settings", {"team_admin_editable_team_fields": editable_fields} - ) - monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", MagicMock()) - monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", None) - monkeypatch.setattr("litellm.proxy.proxy_server.premium_user", False) - monkeypatch.setattr( - "litellm.proxy.management_endpoints.key_management_endpoints.generate_key_helper_fn", generate_key - ) - await _common_key_generation_helper( - data=data, - user_api_key_dict=UserAPIKeyAuth(user_id=caller, user_role=LitellmUserRoles.INTERNAL_USER), - litellm_changed_by=None, - team_table=team_table, - ) - return generate_key - - @pytest.mark.asyncio - async def test_team_admin_creates_a_key_owned_by_a_member_when_enabled(self, monkeypatch): - generate_key = await self._generate( - monkeypatch, - ["create_member_keys"], - GenerateKeyRequest(user_id="member-1", team_id="team-1"), - self._admin_and_member(), - caller="team-admin-1", - ) - created = generate_key.call_args.kwargs - assert (created["user_id"], created["team_id"]) == ("member-1", "team-1") - - @pytest.mark.asyncio - async def test_team_admin_is_pointed_at_the_setting_when_disabled(self, monkeypatch): - with pytest.raises(HTTPException) as exc: - await self._generate( - monkeypatch, - ["member_key_budgets"], - GenerateKeyRequest(user_id="member-1", team_id="team-1"), - self._admin_and_member(), - caller="team-admin-1", - ) - assert exc.value.status_code == 403 - assert "'create_member_keys'" in str(exc.value.detail) - - @pytest.mark.parametrize( - "data, team_members, caller", - [ - pytest.param( - GenerateKeyRequest(user_id="member-1", team_id="team-1"), - [Member(user_id="member-2", role="user"), Member(user_id="member-1", role="user")], - "member-2", - id="ordinary_member_for_another_member", - ), - pytest.param( - GenerateKeyRequest(user_id="outsider", team_id="team-1"), - [Member(user_id="team-admin-1", role="admin")], - "team-admin-1", - id="team_admin_for_a_user_outside_the_team", - ), - pytest.param( - GenerateKeyRequest(user_id="member-1"), - None, - "team-admin-1", - id="personal_key_for_another_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.asyncio - async def test_enabling_the_permission_grants_nothing_else(self, monkeypatch, data, team_members, caller): - with pytest.raises(HTTPException) as exc: - await self._generate( - monkeypatch, - ["create_member_keys"], - data, - None if team_members is None else self._team(team_members), - caller=caller, - ) - assert exc.value.status_code == 403 - assert "User can only create keys for themselves" in str(exc.value.detail) + + +@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), + ) + + +def test_member_key_route_limit_confines_only_member_keys_to_llm_routes(): + assert _member_key_route_limit(acting_as_team_admin=True) == { + "key_type": "llm_api", + "allowed_routes": ["llm_api_routes"], + } + assert _member_key_route_limit(acting_as_team_admin=False) == {} @pytest.mark.asyncio 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 126473da162..08d47e6410f 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 @@ -3,7 +3,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, @@ -21,6 +27,11 @@ from litellm.proxy.management_endpoints.team_admin_field_permissions import ( 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, ) @@ -210,16 +221,48 @@ class TestTeamAdminMayCreateMemberKeys: def test_malformed_setting_denies(self, raw): assert team_admin_may_create_member_keys({"team_admin_editable_team_fields": raw}) is False - def test_or_raise_passes_when_granted(self): - assert team_admin_member_key_creation_or_raise({"team_admin_editable_team_fields": ["create_member_keys"]}) is None - def test_or_raise_is_a_403_naming_the_permission_and_where_to_enable_it(self): +_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({"team_admin_editable_team_fields": ["member_key_budgets"]}) + 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): diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index e7e736826e4..743965cfcfc 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -8333,7 +8333,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. 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`. + * - 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.