From 774444fcd79295e26af0cc38c80a9485a4d191fa Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Mon, 17 Aug 2026 12:41:56 -0700 Subject: [PATCH] feat(teams)!: only accept team member permission grants that are enforced Team member permission writes previously accepted every KeyManagementRoutes value (and /team/permissions_update accepted any string at all), but only nine of those grants are ever read by the permission checks; values like /key/block or /key/bulk_update guard admin-only endpoints and the templated /key/{key_id}/... entries are enforced under their plain counterparts, so granting them silently did nothing. A new TeamMemberPermissions enum narrows the write surfaces (default team settings, /team/permissions_update, /team/permissions_bulk_update) to the nine enforced grants plus the always-included /key/info and /key/health, /team/permissions_list now advertises only the nine grantable values, and the default team settings form derives its checkboxes from the new enum BREAKING CHANGE: PATCH /update/default_team_settings, POST /team/permissions_update, POST /team/permissions_bulk_update, and litellm_settings.default_team_params now reject team member permission values that were never enforced: /key/block, /key/unblock, /key/bulk_update, /team/key/bulk_update, /key/{key_id}/regenerate, /key/{key_id}/reset_spend, /key/aliases, /spend/logs/v2. Stored values are unaffected and still read back --- litellm/proxy/_types.py | 22 +++++++ .../management_endpoints/team_endpoints.py | 4 +- .../team_member_permission_checks.py | 11 ++-- .../management_endpoints/team_endpoints.py | 6 +- .../proxy/management_endpoints/ui_sso.py | 4 +- .../test_team_member_permission_checks.py | 15 +++++ .../proxy/management_endpoints/__init__.py | 0 .../test_team_endpoints.py | 27 ++++++++ .../proxy/management_endpoints/test_ui_sso.py | 63 +++++++++++++++++++ .../DefaultTeamSettingsForm.tsx | 4 +- .../default-team-settings/permissions.ts | 20 ++---- .../default-team-settings/schema.ts | 4 +- ui/litellm-dashboard/src/lib/http/schema.d.ts | 21 ++++--- 13 files changed, 161 insertions(+), 40 deletions(-) create mode 100644 tests/test_litellm/types/proxy/management_endpoints/__init__.py create mode 100644 tests/test_litellm/types/proxy/management_endpoints/test_team_endpoints.py create mode 100644 tests/test_litellm/types/proxy/management_endpoints/test_ui_sso.py diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index a566d491597..55f36f53ce3 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -293,6 +293,28 @@ class KeyManagementRoutes(str, enum.Enum): SPEND_LOGS_V2 = "/spend/logs/v2" +class TeamMemberPermissions(str, enum.Enum): + """ + The subset of KeyManagementRoutes accepted as team member permission + grants. The remaining KeyManagementRoutes values are admin-gated or + enforced under one of these grants, so accepting them would store a + grant that silently never takes effect. + """ + + KEY_GENERATE = KeyManagementRoutes.KEY_GENERATE.value + KEY_UPDATE = KeyManagementRoutes.KEY_UPDATE.value + KEY_DELETE = KeyManagementRoutes.KEY_DELETE.value + KEY_REGENERATE = KeyManagementRoutes.KEY_REGENERATE.value + KEY_GENERATE_SERVICE_ACCOUNT = KeyManagementRoutes.KEY_GENERATE_SERVICE_ACCOUNT.value + KEY_LIST = KeyManagementRoutes.KEY_LIST.value + KEY_ACCESS_GROUP_ASSIGNMENT = KeyManagementRoutes.KEY_ACCESS_GROUP_ASSIGNMENT.value + SPEND_LOGS = KeyManagementRoutes.SPEND_LOGS.value + TEAM_DAILY_ACTIVITY = KeyManagementRoutes.TEAM_DAILY_ACTIVITY.value + + KEY_INFO = KeyManagementRoutes.KEY_INFO.value + KEY_HEALTH = KeyManagementRoutes.KEY_HEALTH.value + + class LiteLLMRoutes(enum.Enum): openai_route_names = [ "chat_completion", diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py index 3d7f0808fb9..6fad0282b67 100644 --- a/litellm/proxy/management_endpoints/team_endpoints.py +++ b/litellm/proxy/management_endpoints/team_endpoints.py @@ -5540,7 +5540,7 @@ async def update_team_member_permissions( # Update the team member permissions updated_team: Final = await _team_db(prisma_client).update( where={"team_id": data.team_id}, - data={"team_member_permissions": data.team_member_permissions}, + data={"team_member_permissions": [permission.value for permission in data.team_member_permissions]}, ) return updated_team @@ -5594,7 +5594,7 @@ async def bulk_update_team_member_permissions( detail={"error": "Cannot set both apply_to_all_teams=true and team_ids"}, ) - permissions_to_add: Final = set(data.permissions) + permissions_to_add: Final = {permission.value for permission in data.permissions} if data.team_ids: teams_updated = await _append_permissions_to_specific_teams(prisma_client, data.team_ids, permissions_to_add) diff --git a/litellm/proxy/management_helpers/team_member_permission_checks.py b/litellm/proxy/management_helpers/team_member_permission_checks.py index 86c7a7bd947..537daf3296e 100644 --- a/litellm/proxy/management_helpers/team_member_permission_checks.py +++ b/litellm/proxy/management_helpers/team_member_permission_checks.py @@ -4,11 +4,11 @@ from litellm.proxy._types import ( KeyManagementRoutes, LiteLLM_TeamTableCachedObj, LiteLLM_VerificationToken, - LiteLLMRoutes, LitellmUserRoles, Member, ProxyErrorTypes, ProxyException, + TeamMemberPermissions, UserAPIKeyAuth, ) from litellm.proxy.auth.auth_checks import get_team_object @@ -235,12 +235,11 @@ class TeamMemberPermissionChecks: @staticmethod def get_all_available_team_member_permissions() -> list[str]: """ - Returns all available team member permissions + Returns the team member permissions that can be granted: every + TeamMemberPermissions value except the always-included baseline pair. """ - all_available_permissions: Final = [] - for route in LiteLLMRoutes.key_management_routes.value: - all_available_permissions.append(route) - return all_available_permissions + baseline: Final = {route.value for route in BASELINE_TEAM_MEMBER_PERMISSIONS} + return [permission.value for permission in TeamMemberPermissions if permission.value not in baseline] @staticmethod def default_team_member_permissions() -> list[str]: diff --git a/litellm/types/proxy/management_endpoints/team_endpoints.py b/litellm/types/proxy/management_endpoints/team_endpoints.py index 2417868fb29..c72c8ed4888 100644 --- a/litellm/types/proxy/management_endpoints/team_endpoints.py +++ b/litellm/types/proxy/management_endpoints/team_endpoints.py @@ -3,11 +3,11 @@ from typing import Any, Literal from pydantic import BaseModel, ConfigDict, Field from litellm.proxy._types import ( - KeyManagementRoutes, LiteLLM_DeletedTeamTable, LiteLLM_TeamMembership, LiteLLM_TeamTable, Member, + TeamMemberPermissions, ) TeamIdSearchMatch = Literal["exact", "prefix"] @@ -42,13 +42,13 @@ class UpdateTeamMemberPermissionsRequest(BaseModel): """Request to update the team member permissions for a team""" team_id: str - team_member_permissions: list[str] + team_member_permissions: list[TeamMemberPermissions] class BulkUpdateTeamMemberPermissionsRequest(BaseModel): """Request to bulk-update team member permissions across teams.""" - permissions: list[KeyManagementRoutes] + permissions: list[TeamMemberPermissions] """Permissions to append to the target teams (duplicates are skipped).""" team_ids: list[str] | None = None diff --git a/litellm/types/proxy/management_endpoints/ui_sso.py b/litellm/types/proxy/management_endpoints/ui_sso.py index b4691b9b08c..f0b944866b9 100644 --- a/litellm/types/proxy/management_endpoints/ui_sso.py +++ b/litellm/types/proxy/management_endpoints/ui_sso.py @@ -3,7 +3,7 @@ from typing import Literal from pydantic import Field from typing_extensions import TypedDict -from litellm.proxy._types import KeyManagementRoutes, LitellmUserRoles +from litellm.proxy._types import LitellmUserRoles, TeamMemberPermissions from litellm.types.utils import LiteLLMPydanticObjectBase @@ -226,7 +226,7 @@ class DefaultTeamSSOParams(LiteLLMPydanticObjectBase): default=None, description="Default rpm limit for new teams, when not explicitly provided", ) - team_member_permissions: list[KeyManagementRoutes] | None = Field( + team_member_permissions: list[TeamMemberPermissions] | None = Field( default=None, description="Default permissions granted to members of newly created teams (e.g. /key/generate, /key/update, /key/delete). /key/info and /key/health are always included.", ) diff --git a/tests/test_litellm/proxy/management_helpers/test_team_member_permission_checks.py b/tests/test_litellm/proxy/management_helpers/test_team_member_permission_checks.py index 71999e29f96..3d50e35a188 100644 --- a/tests/test_litellm/proxy/management_helpers/test_team_member_permission_checks.py +++ b/tests/test_litellm/proxy/management_helpers/test_team_member_permission_checks.py @@ -23,6 +23,21 @@ def _make_team_table(team_member_permissions): class TestGetPermissionsForTeamMember: + def test_all_available_permissions_are_the_grantable_set_without_the_baseline(self): + available = TeamMemberPermissionChecks.get_all_available_team_member_permissions() + + assert sorted(available) == [ + "/key/access_group_assignment", + "/key/delete", + "/key/generate", + "/key/list", + "/key/regenerate", + "/key/service-account/generate", + "/key/update", + "/spend/logs", + "/team/daily/activity", + ] + def test_none_permissions_returns_defaults(self): """When team_member_permissions is None, return DEFAULT_TEAM_MEMBER_PERMISSIONS.""" team = _make_team_table(None) diff --git a/tests/test_litellm/types/proxy/management_endpoints/__init__.py b/tests/test_litellm/types/proxy/management_endpoints/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/test_litellm/types/proxy/management_endpoints/test_team_endpoints.py b/tests/test_litellm/types/proxy/management_endpoints/test_team_endpoints.py new file mode 100644 index 00000000000..7244b040d35 --- /dev/null +++ b/tests/test_litellm/types/proxy/management_endpoints/test_team_endpoints.py @@ -0,0 +1,27 @@ +import pytest +from pydantic import ValidationError + +from litellm.types.proxy.management_endpoints.team_endpoints import ( + BulkUpdateTeamMemberPermissionsRequest, + UpdateTeamMemberPermissionsRequest, +) + + +def test_permissions_update_accepts_enforced_grants(): + request = UpdateTeamMemberPermissionsRequest( + team_id="team-1", + team_member_permissions=["/key/generate", "/key/delete"], + ) + assert [permission.value for permission in request.team_member_permissions] == ["/key/generate", "/key/delete"] + + +@pytest.mark.parametrize("dead_route", ["/key/block", "/key/bulk_update", "/made/up/route"]) +def test_permissions_update_rejects_unenforced_grants(dead_route): + with pytest.raises(ValidationError): + UpdateTeamMemberPermissionsRequest(team_id="team-1", team_member_permissions=[dead_route]) + + +@pytest.mark.parametrize("dead_route", ["/key/unblock", "/spend/logs/v2"]) +def test_bulk_permissions_update_rejects_unenforced_grants(dead_route): + with pytest.raises(ValidationError): + BulkUpdateTeamMemberPermissionsRequest(permissions=[dead_route], apply_to_all_teams=True) diff --git a/tests/test_litellm/types/proxy/management_endpoints/test_ui_sso.py b/tests/test_litellm/types/proxy/management_endpoints/test_ui_sso.py new file mode 100644 index 00000000000..3e8e4bbd192 --- /dev/null +++ b/tests/test_litellm/types/proxy/management_endpoints/test_ui_sso.py @@ -0,0 +1,63 @@ +import pytest +from pydantic import ValidationError + +from litellm.proxy._types import KeyManagementRoutes, TeamMemberPermissions +from litellm.types.proxy.management_endpoints.ui_sso import DefaultTeamSSOParams + +GRANTABLE_PERMISSIONS = sorted( + permission.value + for permission in TeamMemberPermissions + if permission not in (TeamMemberPermissions.KEY_INFO, TeamMemberPermissions.KEY_HEALTH) +) + +DEAD_KEY_MANAGEMENT_ROUTES = sorted( + {route.value for route in KeyManagementRoutes} - {permission.value for permission in TeamMemberPermissions} +) + + +def test_every_grantable_permission_is_a_key_management_route(): + key_management_values = {route.value for route in KeyManagementRoutes} + assert {permission.value for permission in TeamMemberPermissions} <= key_management_values + + +def test_grantable_permissions_are_exactly_the_enforced_set(): + assert GRANTABLE_PERMISSIONS == [ + "/key/access_group_assignment", + "/key/delete", + "/key/generate", + "/key/list", + "/key/regenerate", + "/key/service-account/generate", + "/key/update", + "/spend/logs", + "/team/daily/activity", + ] + + +def test_default_team_params_accept_every_grantable_permission(): + params = DefaultTeamSSOParams(team_member_permissions=GRANTABLE_PERMISSIONS) + assert [permission.value for permission in params.team_member_permissions or []] == GRANTABLE_PERMISSIONS + + +def test_default_team_params_accept_the_always_included_baseline_pair(): + params = DefaultTeamSSOParams(team_member_permissions=["/key/info", "/key/health"]) + assert [permission.value for permission in params.team_member_permissions or []] == ["/key/info", "/key/health"] + + +@pytest.mark.parametrize("dead_route", DEAD_KEY_MANAGEMENT_ROUTES) +def test_default_team_params_reject_grants_that_are_never_enforced(dead_route): + with pytest.raises(ValidationError): + DefaultTeamSSOParams(team_member_permissions=[dead_route]) + + +def test_dead_routes_cover_the_known_unenforced_grants(): + assert DEAD_KEY_MANAGEMENT_ROUTES == [ + "/key/aliases", + "/key/block", + "/key/bulk_update", + "/key/unblock", + "/key/{key_id}/regenerate", + "/key/{key_id}/reset_spend", + "/spend/logs/v2", + "/team/key/bulk_update", + ] diff --git a/ui/litellm-dashboard/src/app/(dashboard)/teams/_components/default-team-settings/DefaultTeamSettingsForm.tsx b/ui/litellm-dashboard/src/app/(dashboard)/teams/_components/default-team-settings/DefaultTeamSettingsForm.tsx index 34d530d7212..4f1a4a51a2a 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/teams/_components/default-team-settings/DefaultTeamSettingsForm.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/teams/_components/default-team-settings/DefaultTeamSettingsForm.tsx @@ -23,7 +23,7 @@ import { useZodForm } from "@/lib/forms/useZodForm"; import { fetchClient } from "@/lib/http/api"; import { buildBody, settingsToForm, type DefaultTeamParams, type DefaultTeamSettings } from "./mapper"; -import { SELECTABLE_PERMISSIONS, type KeyManagementRoute } from "./permissions"; +import { SELECTABLE_PERMISSIONS, type TeamMemberPermission } from "./permissions"; import { defaultTeamSettingsSchema, type DefaultTeamSettingsFormValues } from "./schema"; const MODEL_SENTINEL_LABELS: ReadonlyMap = new Map( @@ -54,7 +54,7 @@ const PermissionRow = ({ granted, onToggle, }: { - route: KeyManagementRoute; + route: TeamMemberPermission; granted: boolean; onToggle: (checked: boolean) => void; }) => { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/teams/_components/default-team-settings/permissions.ts b/ui/litellm-dashboard/src/app/(dashboard)/teams/_components/default-team-settings/permissions.ts index b400012bfa5..83293cfaff4 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/teams/_components/default-team-settings/permissions.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/teams/_components/default-team-settings/permissions.ts @@ -1,32 +1,24 @@ import type { components } from "@/lib/http/schema"; -export type KeyManagementRoute = components["schemas"]["KeyManagementRoutes"]; +export type TeamMemberPermission = components["schemas"]["TeamMemberPermissions"]; -const PERMISSION_IS_SELECTABLE: Record = { +const PERMISSION_IS_SELECTABLE: Record = { "/key/generate": true, "/key/update": true, "/key/delete": true, "/key/regenerate": true, - "/key/{key_id}/regenerate": true, "/key/service-account/generate": true, - "/key/block": true, - "/key/unblock": true, - "/key/bulk_update": true, - "/team/key/bulk_update": true, - "/key/{key_id}/reset_spend": true, "/key/access_group_assignment": true, - "/key/info": true, "/key/list": true, - "/key/aliases": true, "/team/daily/activity": true, "/spend/logs": true, - "/spend/logs/v2": true, + "/key/info": false, "/key/health": false, }; -export const SELECTABLE_PERMISSIONS: readonly KeyManagementRoute[] = ( - Object.keys(PERMISSION_IS_SELECTABLE) as KeyManagementRoute[] +export const SELECTABLE_PERMISSIONS: readonly TeamMemberPermission[] = ( + Object.keys(PERMISSION_IS_SELECTABLE) as TeamMemberPermission[] ).filter((route) => PERMISSION_IS_SELECTABLE[route]); -export const isSelectablePermission = (value: string): value is KeyManagementRoute => +export const isSelectablePermission = (value: string): value is TeamMemberPermission => (SELECTABLE_PERMISSIONS as readonly string[]).includes(value); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/teams/_components/default-team-settings/schema.ts b/ui/litellm-dashboard/src/app/(dashboard)/teams/_components/default-team-settings/schema.ts index da4f45fe7e8..f72538ee73e 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/teams/_components/default-team-settings/schema.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/teams/_components/default-team-settings/schema.ts @@ -1,6 +1,6 @@ import { z } from "zod/v4"; -import { isSelectablePermission, type KeyManagementRoute } from "./permissions"; +import { isSelectablePermission, type TeamMemberPermission } from "./permissions"; const isBlank = (value: string): boolean => value.trim() === ""; @@ -18,7 +18,7 @@ const wholeNumberOrEmpty = z "Must be a non-negative whole number", ); -const selectablePermission = z.custom( +const selectablePermission = z.custom( (value) => typeof value === "string" && isSelectablePermission(value), "Unknown permission", ); diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index cf37709c377..f9d4af2742a 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -22593,7 +22593,7 @@ export interface components { */ apply_to_all_teams: boolean; /** Permissions */ - permissions: components["schemas"]["KeyManagementRoutes"][]; + permissions: components["schemas"]["TeamMemberPermissions"][]; /** Team Ids */ team_ids?: string[] | null; }; @@ -24560,7 +24560,7 @@ export interface components { * Team Member Permissions * @description Default permissions granted to members of newly created teams (e.g. /key/generate, /key/update, /key/delete). /key/info and /key/health are always included. */ - team_member_permissions?: components["schemas"]["KeyManagementRoutes"][] | null; + team_member_permissions?: components["schemas"]["TeamMemberPermissions"][] | null; /** * Tpm Limit * @description Default tpm limit for new teams, when not explicitly provided @@ -25893,12 +25893,6 @@ export interface components { /** Total Pages */ total_pages?: number | null; }; - /** - * KeyManagementRoutes - * @description Enum for key management routes - * @enum {string} - */ - KeyManagementRoutes: "/key/generate" | "/key/update" | "/key/delete" | "/key/regenerate" | "/key/service-account/generate" | "/key/{key_id}/regenerate" | "/key/block" | "/key/unblock" | "/key/bulk_update" | "/team/key/bulk_update" | "/key/{key_id}/reset_spend" | "/key/access_group_assignment" | "/key/info" | "/key/health" | "/key/list" | "/key/aliases" | "/team/daily/activity" | "/spend/logs" | "/spend/logs/v2"; /** * KeyManagementSystem * @enum {string} @@ -33597,6 +33591,15 @@ export interface components { /** User Id */ user_id: string; }; + /** + * TeamMemberPermissions + * @description The subset of KeyManagementRoutes accepted as team member permission + * grants. The remaining KeyManagementRoutes values are admin-gated or + * enforced under one of these grants, so accepting them would store a + * grant that silently never takes effect. + * @enum {string} + */ + TeamMemberPermissions: "/key/generate" | "/key/update" | "/key/delete" | "/key/regenerate" | "/key/service-account/generate" | "/key/list" | "/key/access_group_assignment" | "/spend/logs" | "/team/daily/activity" | "/key/info" | "/key/health"; /** TeamMemberUpdateRequest */ TeamMemberUpdateRequest: { /** @@ -34645,7 +34648,7 @@ export interface components { /** Team Id */ team_id: string; /** Team Member Permissions */ - team_member_permissions: string[]; + team_member_permissions: components["schemas"]["TeamMemberPermissions"][]; }; /** * UpdateTeamRequest