feat(teams)!: only accept team member permission grants that are enforced
Some checks failed
LiteLLM Rust / rustfmt, clippy, test (push) Has been cancelled
Terraform Modules / fmt, validate, test (aws) (push) Has been cancelled
Terraform Provider / gofmt, vet, build, test (push) Has been cancelled
Terraform Provider / Provider endpoints vs proxy OpenAPI schema (push) Has been cancelled

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
This commit is contained in:
ryan-crabbe-berri 2026-08-17 12:41:56 -07:00
parent 22e600fe5a
commit 774444fcd7
13 changed files with 161 additions and 40 deletions

View file

@ -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",

View file

@ -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)

View file

@ -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]:

View file

@ -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

View file

@ -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.",
)

View file

@ -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)

View file

@ -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)

View file

@ -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",
]

View file

@ -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<string, string> = new Map(
@ -54,7 +54,7 @@ const PermissionRow = ({
granted,
onToggle,
}: {
route: KeyManagementRoute;
route: TeamMemberPermission;
granted: boolean;
onToggle: (checked: boolean) => void;
}) => {

View file

@ -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<KeyManagementRoute, boolean> = {
const PERMISSION_IS_SELECTABLE: Record<TeamMemberPermission, boolean> = {
"/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);

View file

@ -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<KeyManagementRoute>(
const selectablePermission = z.custom<TeamMemberPermission>(
(value) => typeof value === "string" && isSelectablePermission(value),
"Unknown permission",
);

View file

@ -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