diff --git a/litellm/proxy/management_endpoints/key_management_endpoints.py b/litellm/proxy/management_endpoints/key_management_endpoints.py index 782a7e1db3c..c7b7119b883 100644 --- a/litellm/proxy/management_endpoints/key_management_endpoints.py +++ b/litellm/proxy/management_endpoints/key_management_endpoints.py @@ -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. 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 18bfb6ad350..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"]) @@ -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 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..20fa4735f9f 100644 --- a/tests/unit/proxy/management_endpoints/test_key_management_endpoints.py +++ b/tests/unit/proxy/management_endpoints/test_key_management_endpoints.py @@ -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 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 ada0bb2b8a2..62cc399b72e 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -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.